digitalocean-python-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- digitalocean_python_sdk-0.1.0.dist-info/METADATA +356 -0
- digitalocean_python_sdk-0.1.0.dist-info/RECORD +18 -0
- digitalocean_python_sdk-0.1.0.dist-info/WHEEL +4 -0
- digitalocean_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- digitalocean_sdk/__init__.py +42 -0
- digitalocean_sdk/_base.py +246 -0
- digitalocean_sdk/_version.py +1 -0
- digitalocean_sdk/client.py +33 -0
- digitalocean_sdk/config.py +67 -0
- digitalocean_sdk/errors.py +131 -0
- digitalocean_sdk/messages.py +63 -0
- digitalocean_sdk/messages.yaml +28 -0
- digitalocean_sdk/models.py +157 -0
- digitalocean_sdk/py.typed +0 -0
- digitalocean_sdk/resources/__init__.py +4 -0
- digitalocean_sdk/resources/_paging.py +35 -0
- digitalocean_sdk/resources/dns.py +226 -0
- digitalocean_sdk/resources/domains.py +76 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: digitalocean-python-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A typed Python client for the DigitalOcean Domains and DNS API, sync and async.
|
|
5
|
+
Project-URL: Homepage, https://theqiyas.cc/projects/digitalocean-python-sdk/
|
|
6
|
+
Project-URL: Source, https://github.com/qiyascc/digitalocean-python-sdk
|
|
7
|
+
Project-URL: Author, https://theqiyas.cc
|
|
8
|
+
Project-URL: Issues, https://github.com/qiyascc/digitalocean-python-sdk/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/qiyascc/digitalocean-python-sdk/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: qiyascc <qiyasturk@gmail.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: api,digitalocean,dns,domains,sdk,subdomain
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Internet :: Name Service (DNS)
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: goodconf>=7.1
|
|
26
|
+
Requires-Dist: httpx<1,>=0.24
|
|
27
|
+
Requires-Dist: pydantic>=2.7
|
|
28
|
+
Requires-Dist: pyyaml>=6
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: goodconf[toml]>=7.1; extra == 'dev'
|
|
31
|
+
Requires-Dist: goodconf[yaml]>=7.1; extra == 'dev'
|
|
32
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
35
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
36
|
+
Requires-Dist: types-pyyaml; extra == 'dev'
|
|
37
|
+
Provides-Extra: toml
|
|
38
|
+
Requires-Dist: goodconf[toml]>=7.1; extra == 'toml'
|
|
39
|
+
Provides-Extra: yaml
|
|
40
|
+
Requires-Dist: goodconf[yaml]>=7.1; extra == 'yaml'
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
|
|
43
|
+
# digitalocean-python-sdk
|
|
44
|
+
|
|
45
|
+
A small, typed Python client for the DigitalOcean Domains and DNS API. It works with both sync and async code, waits and retries when DigitalOcean rate limits you, and returns plain dataclasses, not raw JSON.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from digitalocean_sdk import DigitalOcean
|
|
49
|
+
|
|
50
|
+
with DigitalOcean("DIGITALOCEAN_TOKEN") as do:
|
|
51
|
+
do.dns.point("example.com", "shop", "203.0.113.10")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
That call makes `shop.example.com` resolve to `203.0.113.10`. If the record already exists it is updated, if it is missing it is created, and extra `A` records with the same name are removed.
|
|
55
|
+
|
|
56
|
+
## Install
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install digitalocean-python-sdk
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Requires Python 3.10 or newer. Dependencies: [httpx](https://www.python-httpx.org/), [goodconf](https://github.com/lincolnloop/goodconf), pydantic and PyYAML.
|
|
63
|
+
|
|
64
|
+
The import name is `digitalocean_sdk`, so it can live next to the older `python-digitalocean` package without clashing.
|
|
65
|
+
|
|
66
|
+
## Configuration
|
|
67
|
+
|
|
68
|
+
Settings are loaded with [goodconf](https://github.com/lincolnloop/goodconf), so you can configure the client from code, from environment variables or from a config file. Values passed in code win over the environment, and the environment wins over the file.
|
|
69
|
+
|
|
70
|
+
Create a personal access token at [cloud.digitalocean.com/account/api/tokens](https://cloud.digitalocean.com/account/api/tokens). For DNS work the token needs the `domain` scopes (read, create, update, delete). Then pick whichever option suits you:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
do = DigitalOcean("dop_v1_...")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
export DIGITALOCEAN_TOKEN=dop_v1_...
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```toml
|
|
81
|
+
token = "dop_v1_..."
|
|
82
|
+
timeout = 15
|
|
83
|
+
default_ttl = 1800
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`DIGITALOCEAN_TOKEN` is the same variable `doctl` and Terraform use, so an existing setup usually works as is.
|
|
87
|
+
|
|
88
|
+
With no arguments, the client looks for `digitalocean.toml`, `digitalocean.yaml`, `digitalocean.yml` or `digitalocean.json` in the working directory. To load a file from somewhere else, set `DIGITALOCEAN_CONFIG=/path/to/file`, or pass `config_file=` to the client. YAML files need the `yaml` extra, and TOML files on Python 3.10 need the `toml` extra:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pip install "digitalocean-python-sdk[yaml]"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
| Setting | Env variable | Default |
|
|
95
|
+
| --- | --- | --- |
|
|
96
|
+
| `token` | `DIGITALOCEAN_TOKEN` | |
|
|
97
|
+
| `base_url` | `DIGITALOCEAN_BASE_URL` | `https://api.digitalocean.com` |
|
|
98
|
+
| `api_version` | `DIGITALOCEAN_API_VERSION` | `v2` |
|
|
99
|
+
| `endpoints.*` | `DIGITALOCEAN_ENDPOINTS__<NAME>` | see below |
|
|
100
|
+
| `timeout` | `DIGITALOCEAN_TIMEOUT` | `30` |
|
|
101
|
+
| `max_retries` | `DIGITALOCEAN_MAX_RETRIES` | `3` |
|
|
102
|
+
| `max_retry_wait` | `DIGITALOCEAN_MAX_RETRY_WAIT` | `60` |
|
|
103
|
+
| `backoff_base` | `DIGITALOCEAN_BACKOFF_BASE` | `0.5` |
|
|
104
|
+
| `backoff_cap` | `DIGITALOCEAN_BACKOFF_CAP` | `8` |
|
|
105
|
+
| `page_size` | `DIGITALOCEAN_PAGE_SIZE` | `200` (the API maximum) |
|
|
106
|
+
| `default_ttl` | `DIGITALOCEAN_DEFAULT_TTL` | `1800` |
|
|
107
|
+
| `user_agent` | `DIGITALOCEAN_USER_AGENT` | `digitalocean-python-sdk/{version}` |
|
|
108
|
+
|
|
109
|
+
Any setting can also be passed straight to the client, for example `DigitalOcean(timeout=10, max_retries=5)`. If you build settings yourself, hand them over with `settings=`:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from digitalocean_sdk import DigitalOcean, load_settings
|
|
113
|
+
|
|
114
|
+
settings = load_settings("/etc/myapp/digitalocean.toml", default_ttl=300)
|
|
115
|
+
do = DigitalOcean(settings=settings)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Endpoints
|
|
119
|
+
|
|
120
|
+
API paths are part of the settings too, so nothing is pinned in the code. Each one is a template relative to the base URL. `{version}` comes from `api_version`, and the other placeholders are filled in and URL encoded for each call.
|
|
121
|
+
|
|
122
|
+
| Name | Default |
|
|
123
|
+
| --- | --- |
|
|
124
|
+
| `account` | `/{version}/account` |
|
|
125
|
+
| `domains` | `/{version}/domains` |
|
|
126
|
+
| `domain` | `/{version}/domains/{domain}` |
|
|
127
|
+
| `records` | `/{version}/domains/{domain}/records` |
|
|
128
|
+
| `record` | `/{version}/domains/{domain}/records/{record_id}` |
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
export DIGITALOCEAN_ENDPOINTS__RECORDS="/{version}/domains/{domain}/records"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Check that the token works:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
do.account()
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
Account(email='you@example.com', uuid='...', status='active', email_verified=True, droplet_limit=25, ...)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Domains
|
|
145
|
+
|
|
146
|
+
DigitalOcean is not a registrar. A domain here is a zone you manage with DigitalOcean DNS: buy the name anywhere, then set its name servers to `ns1.digitalocean.com`, `ns2.digitalocean.com` and `ns3.digitalocean.com`.
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
do.domains.create("example.com")
|
|
150
|
+
do.domains.create("example.com", ip_address="203.0.113.10")
|
|
151
|
+
|
|
152
|
+
for domain in do.domains.iter():
|
|
153
|
+
print(domain.name, domain.ttl)
|
|
154
|
+
|
|
155
|
+
do.domains.list()
|
|
156
|
+
do.domains.get("example.com").zone_file
|
|
157
|
+
do.domains.exists("example.com")
|
|
158
|
+
do.domains.delete("example.com")
|
|
159
|
+
do.domains.delete("maybe-gone.com", missing_ok=True)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
When `ip_address` is given, DigitalOcean creates an `A` record for the apex right away.
|
|
163
|
+
|
|
164
|
+
## DNS
|
|
165
|
+
|
|
166
|
+
Every DNS method takes the domain first. Record names are relative to the domain and the apex is `@`. You can also pass a full name like `www.example.com`, it is turned into `www` for you.
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
from digitalocean_sdk import DigitalOcean, DNSRecord
|
|
170
|
+
|
|
171
|
+
do = DigitalOcean()
|
|
172
|
+
|
|
173
|
+
do.dns.list("example.com")
|
|
174
|
+
do.dns.list("example.com", "A")
|
|
175
|
+
do.dns.list("example.com", "A", "www")
|
|
176
|
+
do.dns.list("example.com", name="www")
|
|
177
|
+
do.dns.exists("example.com", "A", "shop")
|
|
178
|
+
do.dns.get("example.com", 28448433)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Filtering by type and name happens on DigitalOcean's side. Lists follow every page for you, and `iter()` does the same lazily. Each item is a `DNSRecord`:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
DNSRecord(type='A', name='www', data='203.0.113.10', ttl=1800, priority=None, port=None, weight=None, flags=None, tag=None, id=28448433)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Creating and changing records
|
|
188
|
+
|
|
189
|
+
DigitalOcean identifies records by id. `create` and `add` return the new records with their ids filled in.
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
record = do.dns.create("example.com", DNSRecord.a("api", "203.0.113.20"))
|
|
193
|
+
|
|
194
|
+
do.dns.add(
|
|
195
|
+
"example.com",
|
|
196
|
+
DNSRecord.txt("@", "google-site-verification=abc"),
|
|
197
|
+
DNSRecord.mx("@", "mail.example.com", priority=10),
|
|
198
|
+
DNSRecord.caa("@", "letsencrypt.org"),
|
|
199
|
+
DNSRecord.srv("_sip._udp", "sip.example.com", port=5060),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
do.dns.update("example.com", record, data="203.0.113.21", ttl=300)
|
|
203
|
+
do.dns.update("example.com", 28448433, name="api-v2")
|
|
204
|
+
|
|
205
|
+
record.ttl = 600
|
|
206
|
+
do.dns.update("example.com", record)
|
|
207
|
+
|
|
208
|
+
do.dns.delete("example.com", record)
|
|
209
|
+
do.dns.delete("example.com", 28448433, missing_ok=True)
|
|
210
|
+
do.dns.delete_all("example.com", "A", "old")
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
`update` with fields sends a `PATCH` and changes only those fields. `update` with just a record sends a `PUT` with the whole record.
|
|
214
|
+
|
|
215
|
+
Host names in `CNAME`, `MX`, `NS` and `SRV` records must be fully qualified with a trailing dot. The helpers add that dot for you, so `DNSRecord.cname("docs", "example.github.io")` is sent as `example.github.io.`. Use `@` to point at the apex itself.
|
|
216
|
+
|
|
217
|
+
### Replacing a set of records
|
|
218
|
+
|
|
219
|
+
`replace` makes one type and name hold exactly the values you pass. Matching records are kept, missing ones are created and the rest are deleted. Pass `ttl` to change it on every record in the set.
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
do.dns.replace("example.com", "A", "www", ["203.0.113.10", "203.0.113.11"], ttl=600)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Shortcuts
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
do.dns.point("example.com", "shop", "203.0.113.10")
|
|
229
|
+
do.dns.point("example.com", "shop", "2001:db8::10")
|
|
230
|
+
do.dns.alias("example.com", "docs", "example.github.io")
|
|
231
|
+
do.dns.set_txt("example.com", "_acme-challenge", "token-value", ttl=60)
|
|
232
|
+
do.dns.unpoint("example.com", "shop")
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`point` picks `A` or `AAAA` from the address you pass. `unpoint` deletes the matching records and returns how many it removed, so it is safe to call when nothing is there.
|
|
236
|
+
|
|
237
|
+
### Example: one subdomain per customer
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
from digitalocean_sdk import DigitalOcean
|
|
241
|
+
|
|
242
|
+
SERVER_IP = "203.0.113.10"
|
|
243
|
+
|
|
244
|
+
def create_customer_site(slug: str) -> str:
|
|
245
|
+
with DigitalOcean() as do:
|
|
246
|
+
if do.dns.exists("example.com", "A", slug):
|
|
247
|
+
raise ValueError(f"{slug}.example.com is already taken")
|
|
248
|
+
do.dns.point("example.com", slug, SERVER_IP, ttl=300)
|
|
249
|
+
return f"{slug}.example.com"
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Async
|
|
253
|
+
|
|
254
|
+
`AsyncDigitalOcean` has the same methods. Await them.
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
import asyncio
|
|
258
|
+
from digitalocean_sdk import AsyncDigitalOcean
|
|
259
|
+
|
|
260
|
+
async def main():
|
|
261
|
+
async with AsyncDigitalOcean() as do:
|
|
262
|
+
records = await do.dns.list("example.com", "A")
|
|
263
|
+
await do.dns.point("example.com", "shop", "203.0.113.10")
|
|
264
|
+
async for domain in do.domains.iter():
|
|
265
|
+
print(domain.name)
|
|
266
|
+
|
|
267
|
+
asyncio.run(main())
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## Other endpoints
|
|
271
|
+
|
|
272
|
+
The SDK focuses on Domains and DNS, but the same authenticated client can call any other DigitalOcean endpoint, with the same retries and errors:
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
droplets = do.request("GET", "/v2/droplets", params={"per_page": 50})
|
|
276
|
+
do.request("POST", "/v2/droplets/123/actions", json={"type": "reboot"})
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## Errors
|
|
280
|
+
|
|
281
|
+
All exceptions inherit from `DigitalOceanError`.
|
|
282
|
+
|
|
283
|
+
| Exception | When |
|
|
284
|
+
| --- | --- |
|
|
285
|
+
| `ValidationError` | 400 or 422, the request body or parameters were rejected |
|
|
286
|
+
| `AuthenticationError` | 401, or no token was found |
|
|
287
|
+
| `PermissionDeniedError` | 403, often a token without the right scope |
|
|
288
|
+
| `NotFoundError` | 404 |
|
|
289
|
+
| `ConflictError` | 409 |
|
|
290
|
+
| `RateLimitError` | 429 that is still failing after retries. Has `.retry_after` |
|
|
291
|
+
| `ServerError` | 5xx |
|
|
292
|
+
| `APIConnectionError` / `APITimeoutError` | the request never got an answer |
|
|
293
|
+
|
|
294
|
+
Status errors carry `status_code`, DigitalOcean's error `code` (for example `not_found` or `unprocessable_entity`), the `request_id` to quote to support, and the raw `response`.
|
|
295
|
+
|
|
296
|
+
```python
|
|
297
|
+
from digitalocean_sdk import ValidationError
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
do.dns.create("example.com", DNSRecord.cname("@", "elsewhere.com"))
|
|
301
|
+
except ValidationError as exc:
|
|
302
|
+
print(exc.code, exc.message, exc.request_id)
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
## Retries and timeouts
|
|
306
|
+
|
|
307
|
+
DigitalOcean allows 5,000 requests per hour and 250 per minute. When it answers 429, the client waits until the `ratelimit-reset` time it sends and tries again, as long as that wait is under `max_retry_wait`. Server errors and dropped connections are retried with exponential backoff, but only for idempotent methods (`GET`, `PUT`, `DELETE`). A `POST` that creates a record is never sent twice.
|
|
308
|
+
|
|
309
|
+
```python
|
|
310
|
+
do = DigitalOcean(timeout=10, max_retries=5, max_retry_wait=30)
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Set `max_retries=0` to turn retries off. To use proxies or a custom transport, pass your own `httpx.Client` or `httpx.AsyncClient` as `http_client=`.
|
|
314
|
+
|
|
315
|
+
## Messages
|
|
316
|
+
|
|
317
|
+
Every error and validation message the SDK produces is stored in `digitalocean_sdk/messages.yaml`, so none of them are hardcoded. You can replace some or all of them, for example to translate them:
|
|
318
|
+
|
|
319
|
+
```yaml
|
|
320
|
+
value:
|
|
321
|
+
empty_name: Record adı boş ola bilməz
|
|
322
|
+
error:
|
|
323
|
+
timeout: "Sorğu vaxtı bitdi: {error}"
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
```python
|
|
327
|
+
from digitalocean_sdk import messages
|
|
328
|
+
|
|
329
|
+
messages.use("messages.az.yaml")
|
|
330
|
+
messages.use({"value.empty_name": "Record adı boş ola bilməz"})
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Unknown keys raise `KeyError`, which catches typos early. Call `messages.reset()` to go back to the defaults.
|
|
334
|
+
|
|
335
|
+
## Development
|
|
336
|
+
|
|
337
|
+
```bash
|
|
338
|
+
python -m venv .venv
|
|
339
|
+
. .venv/bin/activate
|
|
340
|
+
pip install -e ".[dev]"
|
|
341
|
+
pytest
|
|
342
|
+
ruff check .
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
The tests use `httpx.MockTransport`, so they never touch the real API.
|
|
346
|
+
|
|
347
|
+
## Links
|
|
348
|
+
|
|
349
|
+
- Source code: [github.com/qiyascc/digitalocean-python-sdk](https://github.com/qiyascc/digitalocean-python-sdk)
|
|
350
|
+
- PyPI: [pypi.org/project/digitalocean-python-sdk](https://pypi.org/project/digitalocean-python-sdk/)
|
|
351
|
+
- Project page: [theqiyas.cc/projects/digitalocean-python-sdk](https://theqiyas.cc/projects/digitalocean-python-sdk/)
|
|
352
|
+
- Author: [Qiyas Sadıqov](https://theqiyas.cc), more projects at [theqiyas.cc](https://theqiyas.cc)
|
|
353
|
+
|
|
354
|
+
## License
|
|
355
|
+
|
|
356
|
+
MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
digitalocean_sdk/__init__.py,sha256=EhVUGklF1XU418IJLxMXCj3M3pD6Rgv1VSfhiOelOFU,926
|
|
2
|
+
digitalocean_sdk/_base.py,sha256=d9Tt9FeDMwS1b-NvLm7iIBqJnI47rgxsW9MllOYcygA,8761
|
|
3
|
+
digitalocean_sdk/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
4
|
+
digitalocean_sdk/client.py,sha256=VRfQEoX7EWhk0GtWeCoKL1GNjIHMARr3FGnLDa4Vd9A,1042
|
|
5
|
+
digitalocean_sdk/config.py,sha256=cQXoIMhGPjxgsCxIJvR5pyEgnJD9XBS1NlcaMLy_uko,2994
|
|
6
|
+
digitalocean_sdk/errors.py,sha256=jKqQBVhcpb_wpGqARm14W66VEvxdNkw_c8mCNtptTAk,3351
|
|
7
|
+
digitalocean_sdk/messages.py,sha256=Us_YoaNd64bApY58c9gbphy22V8IZ9SKmJOepMGo5WU,1635
|
|
8
|
+
digitalocean_sdk/messages.yaml,sha256=7W-dwzU-cLLac8hS24lib8UwSPkCRNjerymjYUfmUqE,1577
|
|
9
|
+
digitalocean_sdk/models.py,sha256=AFFrPa8dpbidYXLQdIDMkSZlxRAQ7D1ol-WnzdcUqzI,4915
|
|
10
|
+
digitalocean_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
digitalocean_sdk/resources/__init__.py,sha256=SNR0uMexYuMHV2kL34CGR23D_3XOu7-EM9YsY0-IwqI,178
|
|
12
|
+
digitalocean_sdk/resources/_paging.py,sha256=Dr-U0uGUIHXqRTmukIR9HcvhWSh0iQbZhyDdBhtYFjg,1141
|
|
13
|
+
digitalocean_sdk/resources/dns.py,sha256=h3xffv5WMgxrgrNFDJRP20Z7UAk1QZ84PM0SH_SY9ZU,9720
|
|
14
|
+
digitalocean_sdk/resources/domains.py,sha256=EbzOhy9GumJSAz7MKrwi1-1lIUIIIDjPsfE39HtJzCs,2803
|
|
15
|
+
digitalocean_python_sdk-0.1.0.dist-info/METADATA,sha256=InaJGdrA_ARITvY8_CeanRYFX-xX2sTNdTiUUmREWR4,13327
|
|
16
|
+
digitalocean_python_sdk-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
17
|
+
digitalocean_python_sdk-0.1.0.dist-info/licenses/LICENSE,sha256=ENVL-_Eh4n1SumZuLuTjO2K0OOL_eXh4pkeme35b02I,1064
|
|
18
|
+
digitalocean_python_sdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 qiyascc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from . import messages
|
|
2
|
+
from ._version import __version__
|
|
3
|
+
from .client import AsyncDigitalOcean, DigitalOcean
|
|
4
|
+
from .config import Settings, load_settings
|
|
5
|
+
from .errors import (
|
|
6
|
+
APIConnectionError,
|
|
7
|
+
APIStatusError,
|
|
8
|
+
APITimeoutError,
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
ConflictError,
|
|
11
|
+
DigitalOceanError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
PermissionDeniedError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
ServerError,
|
|
16
|
+
ValidationError,
|
|
17
|
+
)
|
|
18
|
+
from .models import Account, DNSRecord, Domain, RecordType
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"__version__",
|
|
22
|
+
"DigitalOcean",
|
|
23
|
+
"AsyncDigitalOcean",
|
|
24
|
+
"Settings",
|
|
25
|
+
"load_settings",
|
|
26
|
+
"messages",
|
|
27
|
+
"Account",
|
|
28
|
+
"DNSRecord",
|
|
29
|
+
"Domain",
|
|
30
|
+
"RecordType",
|
|
31
|
+
"DigitalOceanError",
|
|
32
|
+
"APIConnectionError",
|
|
33
|
+
"APITimeoutError",
|
|
34
|
+
"APIStatusError",
|
|
35
|
+
"ValidationError",
|
|
36
|
+
"AuthenticationError",
|
|
37
|
+
"PermissionDeniedError",
|
|
38
|
+
"NotFoundError",
|
|
39
|
+
"ConflictError",
|
|
40
|
+
"RateLimitError",
|
|
41
|
+
"ServerError",
|
|
42
|
+
]
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import random
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Callable, Generator, Mapping, Optional, TypeVar
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .config import Settings, load_settings
|
|
12
|
+
from .errors import APIConnectionError, APITimeoutError, error_from_response, retry_after_from
|
|
13
|
+
from .messages import text
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "PUT", "DELETE", "OPTIONS"})
|
|
18
|
+
RETRY_STATUSES = frozenset({500, 502, 503, 504})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Call:
|
|
23
|
+
method: str
|
|
24
|
+
path: str
|
|
25
|
+
params: Optional[Mapping[str, Any]] = None
|
|
26
|
+
json: Any = None
|
|
27
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
28
|
+
parse: Callable[[Any], Any] = lambda body: body
|
|
29
|
+
allow_status: frozenset[int] = frozenset()
|
|
30
|
+
|
|
31
|
+
def clean_params(self) -> Optional[dict[str, Any]]:
|
|
32
|
+
if not self.params:
|
|
33
|
+
return None
|
|
34
|
+
cleaned: dict[str, Any] = {}
|
|
35
|
+
for key, value in self.params.items():
|
|
36
|
+
if value is None:
|
|
37
|
+
continue
|
|
38
|
+
cleaned[key] = ("true" if value else "false") if isinstance(value, bool) else value
|
|
39
|
+
return cleaned or None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
Flow = Generator[Call, Any, T]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _body_of(response: httpx.Response) -> Any:
|
|
46
|
+
if response.status_code == 204 or not response.content:
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
return response.json()
|
|
50
|
+
except ValueError:
|
|
51
|
+
return response.text
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BaseClient:
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
token: Optional[str] = None,
|
|
58
|
+
*,
|
|
59
|
+
settings: Optional[Settings] = None,
|
|
60
|
+
config_file: Optional[str] = None,
|
|
61
|
+
**overrides: Any,
|
|
62
|
+
) -> None:
|
|
63
|
+
if settings is None:
|
|
64
|
+
settings = load_settings(config_file, token=token, **overrides)
|
|
65
|
+
elif token or overrides:
|
|
66
|
+
changes = {k: v for k, v in dict(overrides, token=token).items() if v is not None}
|
|
67
|
+
settings = load_settings(None, **{**settings.model_dump(), **changes})
|
|
68
|
+
|
|
69
|
+
self.settings = settings
|
|
70
|
+
self._token = settings.require_token()
|
|
71
|
+
self.base_url = settings.resolved_base_url()
|
|
72
|
+
self.timeout = settings.timeout
|
|
73
|
+
self.max_retries = settings.max_retries
|
|
74
|
+
self.max_retry_wait = settings.max_retry_wait
|
|
75
|
+
self.user_agent = settings.resolved_user_agent()
|
|
76
|
+
|
|
77
|
+
def __repr__(self) -> str:
|
|
78
|
+
return f"{type(self).__name__}(base_url={self.base_url!r})"
|
|
79
|
+
|
|
80
|
+
def _default_headers(self) -> dict[str, str]:
|
|
81
|
+
return {
|
|
82
|
+
"Authorization": f"Bearer {self._token}",
|
|
83
|
+
"Accept": "application/json",
|
|
84
|
+
"Content-Type": "application/json",
|
|
85
|
+
"User-Agent": self.user_agent,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
def _url(self, path: str) -> str:
|
|
89
|
+
return path if path.startswith(("http://", "https://")) else self.base_url + path
|
|
90
|
+
|
|
91
|
+
def _can_retry(self, call: Call, attempt: int) -> bool:
|
|
92
|
+
return attempt < self.max_retries and call.method.upper() in IDEMPOTENT_METHODS
|
|
93
|
+
|
|
94
|
+
def _wait_for(self, attempt: int, retry_after: Optional[float] = None) -> float:
|
|
95
|
+
if retry_after is not None:
|
|
96
|
+
return max(0.0, retry_after)
|
|
97
|
+
delay = self.settings.backoff_base * (2 ** attempt)
|
|
98
|
+
return min(self.settings.backoff_cap, delay) + random.uniform(0, self.settings.backoff_base / 2)
|
|
99
|
+
|
|
100
|
+
def _decide(self, call: Call, response: httpx.Response, attempt: int) -> tuple[str, float]:
|
|
101
|
+
status = response.status_code
|
|
102
|
+
if status < 400 or status in call.allow_status:
|
|
103
|
+
return "done", 0.0
|
|
104
|
+
if status == 429 and attempt < self.max_retries:
|
|
105
|
+
wait = self._wait_for(attempt, retry_after_from(response))
|
|
106
|
+
if wait <= self.max_retry_wait:
|
|
107
|
+
return "retry", wait
|
|
108
|
+
if status in RETRY_STATUSES and self._can_retry(call, attempt):
|
|
109
|
+
return "retry", self._wait_for(attempt)
|
|
110
|
+
return "fail", 0.0
|
|
111
|
+
|
|
112
|
+
def _finish(self, call: Call, response: httpx.Response) -> Any:
|
|
113
|
+
if response.status_code in call.allow_status:
|
|
114
|
+
return call.parse(None)
|
|
115
|
+
return call.parse(_body_of(response))
|
|
116
|
+
|
|
117
|
+
def request(
|
|
118
|
+
self,
|
|
119
|
+
method: str,
|
|
120
|
+
path: str,
|
|
121
|
+
*,
|
|
122
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
123
|
+
json: Any = None,
|
|
124
|
+
missing_ok: bool = False,
|
|
125
|
+
) -> Any:
|
|
126
|
+
allow = frozenset({404}) if missing_ok else frozenset()
|
|
127
|
+
return self._execute(Call(method.upper(), path, params=params, json=json, allow_status=allow))
|
|
128
|
+
|
|
129
|
+
def _execute(self, call: Call) -> Any:
|
|
130
|
+
raise NotImplementedError
|
|
131
|
+
|
|
132
|
+
def _drive(self, flow: Flow[Any]) -> Any:
|
|
133
|
+
raise NotImplementedError
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class SyncClient(BaseClient):
|
|
137
|
+
def __init__(self, *args: Any, http_client: Optional[httpx.Client] = None, **kwargs: Any) -> None:
|
|
138
|
+
super().__init__(*args, **kwargs)
|
|
139
|
+
self._owns_http = http_client is None
|
|
140
|
+
self._http = http_client or httpx.Client(timeout=self.timeout)
|
|
141
|
+
|
|
142
|
+
def close(self) -> None:
|
|
143
|
+
if self._owns_http:
|
|
144
|
+
self._http.close()
|
|
145
|
+
|
|
146
|
+
def __enter__(self: T) -> T:
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
def __exit__(self, *exc: Any) -> None:
|
|
150
|
+
self.close()
|
|
151
|
+
|
|
152
|
+
def _execute(self, call: Call) -> Any:
|
|
153
|
+
headers = {**self._default_headers(), **call.headers}
|
|
154
|
+
attempt = 0
|
|
155
|
+
while True:
|
|
156
|
+
try:
|
|
157
|
+
response = self._http.request(
|
|
158
|
+
call.method, self._url(call.path), params=call.clean_params(), json=call.json, headers=headers
|
|
159
|
+
)
|
|
160
|
+
except httpx.TimeoutException as exc:
|
|
161
|
+
if self._can_retry(call, attempt):
|
|
162
|
+
time.sleep(self._wait_for(attempt))
|
|
163
|
+
attempt += 1
|
|
164
|
+
continue
|
|
165
|
+
raise APITimeoutError(text("error.timeout", error=exc)) from exc
|
|
166
|
+
except httpx.TransportError as exc:
|
|
167
|
+
if self._can_retry(call, attempt):
|
|
168
|
+
time.sleep(self._wait_for(attempt))
|
|
169
|
+
attempt += 1
|
|
170
|
+
continue
|
|
171
|
+
raise APIConnectionError(text("error.connection", error=exc)) from exc
|
|
172
|
+
|
|
173
|
+
action, wait = self._decide(call, response, attempt)
|
|
174
|
+
if action == "done":
|
|
175
|
+
return self._finish(call, response)
|
|
176
|
+
if action == "retry":
|
|
177
|
+
time.sleep(wait)
|
|
178
|
+
attempt += 1
|
|
179
|
+
continue
|
|
180
|
+
raise error_from_response(response)
|
|
181
|
+
|
|
182
|
+
def _drive(self, flow: Flow[Any]) -> Any:
|
|
183
|
+
try:
|
|
184
|
+
call = next(flow)
|
|
185
|
+
while True:
|
|
186
|
+
call = flow.send(self._execute(call))
|
|
187
|
+
except StopIteration as stop:
|
|
188
|
+
return stop.value
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class AsyncClient(BaseClient):
|
|
192
|
+
def __init__(self, *args: Any, http_client: Optional[httpx.AsyncClient] = None, **kwargs: Any) -> None:
|
|
193
|
+
super().__init__(*args, **kwargs)
|
|
194
|
+
self._owns_http = http_client is None
|
|
195
|
+
self._http = http_client or httpx.AsyncClient(timeout=self.timeout)
|
|
196
|
+
|
|
197
|
+
async def close(self) -> None:
|
|
198
|
+
if self._owns_http:
|
|
199
|
+
await self._http.aclose()
|
|
200
|
+
|
|
201
|
+
async def __aenter__(self: T) -> T:
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
205
|
+
await self.close()
|
|
206
|
+
|
|
207
|
+
async def _execute(self, call: Call) -> Any:
|
|
208
|
+
headers = {**self._default_headers(), **call.headers}
|
|
209
|
+
attempt = 0
|
|
210
|
+
while True:
|
|
211
|
+
try:
|
|
212
|
+
response = await self._http.request(
|
|
213
|
+
call.method, self._url(call.path), params=call.clean_params(), json=call.json, headers=headers
|
|
214
|
+
)
|
|
215
|
+
except httpx.TimeoutException as exc:
|
|
216
|
+
if self._can_retry(call, attempt):
|
|
217
|
+
await asyncio.sleep(self._wait_for(attempt))
|
|
218
|
+
attempt += 1
|
|
219
|
+
continue
|
|
220
|
+
raise APITimeoutError(text("error.timeout", error=exc)) from exc
|
|
221
|
+
except httpx.TransportError as exc:
|
|
222
|
+
if self._can_retry(call, attempt):
|
|
223
|
+
await asyncio.sleep(self._wait_for(attempt))
|
|
224
|
+
attempt += 1
|
|
225
|
+
continue
|
|
226
|
+
raise APIConnectionError(text("error.connection", error=exc)) from exc
|
|
227
|
+
|
|
228
|
+
action, wait = self._decide(call, response, attempt)
|
|
229
|
+
if action == "done":
|
|
230
|
+
return self._finish(call, response)
|
|
231
|
+
if action == "retry":
|
|
232
|
+
await asyncio.sleep(wait)
|
|
233
|
+
attempt += 1
|
|
234
|
+
continue
|
|
235
|
+
raise error_from_response(response)
|
|
236
|
+
|
|
237
|
+
async def _drive(self, flow: Flow[Any]) -> Any:
|
|
238
|
+
try:
|
|
239
|
+
call = next(flow)
|
|
240
|
+
while True:
|
|
241
|
+
call = flow.send(await self._execute(call))
|
|
242
|
+
except StopIteration as stop:
|
|
243
|
+
return stop.value
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
__all__ = ["Call", "Flow", "SyncClient", "AsyncClient"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|