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,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ._base import AsyncClient, Call, SyncClient
|
|
6
|
+
from .models import Account
|
|
7
|
+
from .resources import DNS, AsyncDNS, AsyncDomains, Domains
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _account(body: Any) -> Account:
|
|
11
|
+
return Account.from_api((body or {}).get("account") or {})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DigitalOcean(SyncClient):
|
|
15
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
16
|
+
super().__init__(*args, **kwargs)
|
|
17
|
+
self.domains = Domains(self)
|
|
18
|
+
self.dns = DNS(self)
|
|
19
|
+
|
|
20
|
+
def account(self) -> Account:
|
|
21
|
+
result: Account = self._execute(Call("GET", self.settings.path("account"), parse=_account))
|
|
22
|
+
return result
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AsyncDigitalOcean(AsyncClient):
|
|
26
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
27
|
+
super().__init__(*args, **kwargs)
|
|
28
|
+
self.domains = AsyncDomains(self)
|
|
29
|
+
self.dns = AsyncDNS(self)
|
|
30
|
+
|
|
31
|
+
async def account(self) -> Account:
|
|
32
|
+
result: Account = await self._execute(Call("GET", self.settings.path("account"), parse=_account))
|
|
33
|
+
return result
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
from goodconf import Field, GoodConf, GoodConfConfigDict
|
|
7
|
+
from pydantic import BaseModel, SecretStr
|
|
8
|
+
|
|
9
|
+
from ._version import __version__
|
|
10
|
+
from .errors import AuthenticationError
|
|
11
|
+
from .messages import text
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Endpoints(BaseModel):
|
|
15
|
+
account: str = "/{version}/account"
|
|
16
|
+
domains: str = "/{version}/domains"
|
|
17
|
+
domain: str = "/{version}/domains/{domain}"
|
|
18
|
+
records: str = "/{version}/domains/{domain}/records"
|
|
19
|
+
record: str = "/{version}/domains/{domain}/records/{record_id}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Settings(GoodConf):
|
|
23
|
+
model_config = GoodConfConfigDict(
|
|
24
|
+
env_prefix="DIGITALOCEAN_",
|
|
25
|
+
file_env_var="DIGITALOCEAN_CONFIG",
|
|
26
|
+
default_files=["digitalocean.toml", "digitalocean.yaml", "digitalocean.yml", "digitalocean.json"],
|
|
27
|
+
extra="ignore",
|
|
28
|
+
env_nested_delimiter="__",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
token: SecretStr = Field(default=SecretStr(""), description=text("settings.token"))
|
|
32
|
+
base_url: str = Field(default="https://api.digitalocean.com", description=text("settings.base_url"))
|
|
33
|
+
api_version: str = Field(default="v2", description=text("settings.api_version"))
|
|
34
|
+
endpoints: Endpoints = Field(default_factory=Endpoints, description=text("settings.endpoints"))
|
|
35
|
+
timeout: float = Field(default=30.0, gt=0, description=text("settings.timeout"))
|
|
36
|
+
max_retries: int = Field(default=3, ge=0, description=text("settings.max_retries"))
|
|
37
|
+
max_retry_wait: float = Field(default=60.0, ge=0, description=text("settings.max_retry_wait"))
|
|
38
|
+
backoff_base: float = Field(default=0.5, ge=0, description=text("settings.backoff_base"))
|
|
39
|
+
backoff_cap: float = Field(default=8.0, ge=0, description=text("settings.backoff_cap"))
|
|
40
|
+
page_size: int = Field(default=200, ge=1, le=200, description=text("settings.page_size"))
|
|
41
|
+
default_ttl: int = Field(default=1800, ge=30, description=text("settings.default_ttl"))
|
|
42
|
+
user_agent: str = Field(default="digitalocean-python-sdk/{version}", description=text("settings.user_agent"))
|
|
43
|
+
|
|
44
|
+
def resolved_base_url(self) -> str:
|
|
45
|
+
return self.base_url.rstrip("/")
|
|
46
|
+
|
|
47
|
+
def path(self, endpoint: str, **params: Any) -> str:
|
|
48
|
+
template = getattr(self.endpoints, endpoint)
|
|
49
|
+
values = {key: quote(str(value), safe="@") for key, value in params.items()}
|
|
50
|
+
return template.format(version=quote(self.api_version, safe=""), **values)
|
|
51
|
+
|
|
52
|
+
def resolved_user_agent(self) -> str:
|
|
53
|
+
return self.user_agent.replace("{version}", __version__)
|
|
54
|
+
|
|
55
|
+
def require_token(self) -> str:
|
|
56
|
+
token = self.token.get_secret_value().strip()
|
|
57
|
+
if not token:
|
|
58
|
+
raise AuthenticationError(text("error.missing_token"))
|
|
59
|
+
return token
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
Settings.__doc__ = text("settings.description")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_settings(config_file: Optional[str] = None, **overrides: Any) -> Settings:
|
|
66
|
+
given = {key: value for key, value in overrides.items() if value is not None}
|
|
67
|
+
return Settings(load=True, config_file=config_file, **given)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .messages import text
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DigitalOceanError(Exception):
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
message: str,
|
|
15
|
+
*,
|
|
16
|
+
status_code: Optional[int] = None,
|
|
17
|
+
code: Optional[str] = None,
|
|
18
|
+
request_id: Optional[str] = None,
|
|
19
|
+
response: Optional[httpx.Response] = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.message = message
|
|
23
|
+
self.status_code = status_code
|
|
24
|
+
self.code = code
|
|
25
|
+
self.request_id = request_id
|
|
26
|
+
self.response = response
|
|
27
|
+
|
|
28
|
+
def __str__(self) -> str:
|
|
29
|
+
parts = []
|
|
30
|
+
if self.status_code:
|
|
31
|
+
parts.append(f"[{self.status_code}]")
|
|
32
|
+
if self.code:
|
|
33
|
+
parts.append(self.code)
|
|
34
|
+
parts.append(self.message)
|
|
35
|
+
if self.request_id:
|
|
36
|
+
parts.append(f"(request {self.request_id})")
|
|
37
|
+
return " ".join(parts)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class APIConnectionError(DigitalOceanError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class APITimeoutError(APIConnectionError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class APIStatusError(DigitalOceanError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ValidationError(APIStatusError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AuthenticationError(APIStatusError):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PermissionDeniedError(APIStatusError):
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class NotFoundError(APIStatusError):
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ConflictError(APIStatusError):
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RateLimitError(APIStatusError):
|
|
73
|
+
def __init__(self, message: str, *, retry_after: Optional[float] = None, **kwargs: Any) -> None:
|
|
74
|
+
super().__init__(message, **kwargs)
|
|
75
|
+
self.retry_after = retry_after
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ServerError(APIStatusError):
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_STATUS_MAP: dict[int, type[APIStatusError]] = {
|
|
83
|
+
400: ValidationError,
|
|
84
|
+
401: AuthenticationError,
|
|
85
|
+
403: PermissionDeniedError,
|
|
86
|
+
404: NotFoundError,
|
|
87
|
+
409: ConflictError,
|
|
88
|
+
422: ValidationError,
|
|
89
|
+
429: RateLimitError,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def retry_after_from(response: httpx.Response, now: Optional[float] = None) -> Optional[float]:
|
|
94
|
+
header = response.headers.get("Retry-After")
|
|
95
|
+
if header:
|
|
96
|
+
try:
|
|
97
|
+
return max(0.0, float(header))
|
|
98
|
+
except ValueError:
|
|
99
|
+
pass
|
|
100
|
+
reset = response.headers.get("ratelimit-reset")
|
|
101
|
+
if reset:
|
|
102
|
+
try:
|
|
103
|
+
return max(0.0, float(reset) - (now if now is not None else time.time()))
|
|
104
|
+
except ValueError:
|
|
105
|
+
return None
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def error_from_response(response: httpx.Response) -> APIStatusError:
|
|
110
|
+
try:
|
|
111
|
+
body = response.json()
|
|
112
|
+
except ValueError:
|
|
113
|
+
body = None
|
|
114
|
+
|
|
115
|
+
if isinstance(body, dict):
|
|
116
|
+
message = str(body.get("message") or response.reason_phrase or text("error.request_failed"))
|
|
117
|
+
code = body.get("id")
|
|
118
|
+
request_id = body.get("request_id") or response.headers.get("x-request-id")
|
|
119
|
+
else:
|
|
120
|
+
message = response.text.strip() or response.reason_phrase or text("error.request_failed")
|
|
121
|
+
code = None
|
|
122
|
+
request_id = response.headers.get("x-request-id")
|
|
123
|
+
|
|
124
|
+
status = response.status_code
|
|
125
|
+
kwargs: dict[str, Any] = dict(status_code=status, code=code, request_id=request_id, response=response)
|
|
126
|
+
|
|
127
|
+
if status == 429:
|
|
128
|
+
return RateLimitError(message, retry_after=retry_after_from(response), **kwargs)
|
|
129
|
+
if status >= 500:
|
|
130
|
+
return ServerError(message, **kwargs)
|
|
131
|
+
return _STATUS_MAP.get(status, APIStatusError)(message, **kwargs)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from importlib import resources
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Union
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
PathLike = Union[str, Path]
|
|
11
|
+
|
|
12
|
+
_PACKAGE_FILE = "messages.yaml"
|
|
13
|
+
_catalog: dict[str, str] = {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _flatten(node: Mapping[str, Any], prefix: str = "") -> dict[str, str]:
|
|
17
|
+
flat: dict[str, str] = {}
|
|
18
|
+
for key, value in node.items():
|
|
19
|
+
path = f"{prefix}{key}"
|
|
20
|
+
if isinstance(value, Mapping):
|
|
21
|
+
flat.update(_flatten(value, f"{path}."))
|
|
22
|
+
else:
|
|
23
|
+
flat[path] = str(value)
|
|
24
|
+
return flat
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse(raw: str) -> dict[str, str]:
|
|
28
|
+
data = yaml.safe_load(raw) or {}
|
|
29
|
+
if not isinstance(data, Mapping):
|
|
30
|
+
raise ValueError("messages file must contain a mapping")
|
|
31
|
+
return _flatten(data)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read_packaged() -> dict[str, str]:
|
|
35
|
+
raw = resources.files(__package__).joinpath(_PACKAGE_FILE).read_text(encoding="utf-8")
|
|
36
|
+
return _parse(raw)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def catalog() -> Mapping[str, str]:
|
|
40
|
+
if not _catalog:
|
|
41
|
+
_catalog.update(_read_packaged())
|
|
42
|
+
return _catalog
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def use(source: PathLike | Mapping[str, Any]) -> None:
|
|
46
|
+
if isinstance(source, Mapping):
|
|
47
|
+
overrides = _flatten(source)
|
|
48
|
+
else:
|
|
49
|
+
overrides = _parse(Path(source).read_text(encoding="utf-8"))
|
|
50
|
+
unknown = set(overrides) - set(_read_packaged())
|
|
51
|
+
if unknown:
|
|
52
|
+
raise KeyError(", ".join(sorted(unknown)))
|
|
53
|
+
catalog()
|
|
54
|
+
_catalog.update(overrides)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def reset() -> None:
|
|
58
|
+
_catalog.clear()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def text(key: str, **params: Any) -> str:
|
|
62
|
+
template = catalog()[key]
|
|
63
|
+
return template.format(**params) if params else template
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
settings:
|
|
2
|
+
description: Settings for the DigitalOcean API client.
|
|
3
|
+
token: Personal access token from cloud.digitalocean.com/account/api/tokens.
|
|
4
|
+
base_url: API URL without the version part.
|
|
5
|
+
api_version: API version that fills the {version} part of every endpoint.
|
|
6
|
+
endpoints: Path templates for every API call, relative to the base URL.
|
|
7
|
+
timeout: Seconds to wait for a single request.
|
|
8
|
+
max_retries: How many times a failed request is tried again.
|
|
9
|
+
max_retry_wait: Longest wait in seconds the client accepts from a rate limit answer.
|
|
10
|
+
backoff_base: First backoff delay in seconds, doubled on every retry.
|
|
11
|
+
backoff_cap: Longest backoff delay in seconds.
|
|
12
|
+
page_size: Items asked for on every page when listing, between 1 and 200.
|
|
13
|
+
default_ttl: TTL in seconds used for new records when none is given.
|
|
14
|
+
user_agent: User-Agent header. {version} is replaced with the package version.
|
|
15
|
+
error:
|
|
16
|
+
request_failed: Request failed
|
|
17
|
+
missing_token: Missing token. Pass token, set DIGITALOCEAN_TOKEN, or point DIGITALOCEAN_CONFIG at a config file.
|
|
18
|
+
timeout: 'Request timed out: {error}'
|
|
19
|
+
connection: 'Connection failed: {error}'
|
|
20
|
+
value:
|
|
21
|
+
empty_domain: domain must not be empty
|
|
22
|
+
empty_name: record name must not be empty, use '@' for the apex
|
|
23
|
+
empty_type: record type must not be empty
|
|
24
|
+
no_values: at least one record value is required
|
|
25
|
+
add_needs_records: add() needs at least one DNSRecord
|
|
26
|
+
update_needs_fields: update() needs at least one field to change
|
|
27
|
+
needs_id: record {name!r} has no id, fetch it from the API first
|
|
28
|
+
foreign_name: '{name!r} is not inside {domain}'
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any, Optional, Union
|
|
7
|
+
|
|
8
|
+
from .messages import text
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RecordType(str, Enum):
|
|
12
|
+
A = "A"
|
|
13
|
+
AAAA = "AAAA"
|
|
14
|
+
CAA = "CAA"
|
|
15
|
+
CNAME = "CNAME"
|
|
16
|
+
MX = "MX"
|
|
17
|
+
NS = "NS"
|
|
18
|
+
SOA = "SOA"
|
|
19
|
+
SRV = "SRV"
|
|
20
|
+
TXT = "TXT"
|
|
21
|
+
|
|
22
|
+
def __str__(self) -> str:
|
|
23
|
+
return self.value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
RecordTypeLike = Union[RecordType, str]
|
|
27
|
+
HOST_TYPES = frozenset({"CNAME", "MX", "NS", "SRV"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def record_type(value: RecordTypeLike) -> str:
|
|
31
|
+
kind = value.value if isinstance(value, RecordType) else str(value).strip().upper()
|
|
32
|
+
if not kind:
|
|
33
|
+
raise ValueError(text("value.empty_type"))
|
|
34
|
+
return kind
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def hostname(value: str) -> str:
|
|
38
|
+
host = value.strip()
|
|
39
|
+
if host == "@" or host.endswith(".") or "." not in host:
|
|
40
|
+
return host
|
|
41
|
+
return f"{host}."
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class DNSRecord:
|
|
46
|
+
type: str
|
|
47
|
+
name: str
|
|
48
|
+
data: str
|
|
49
|
+
ttl: Optional[int] = None
|
|
50
|
+
priority: Optional[int] = None
|
|
51
|
+
port: Optional[int] = None
|
|
52
|
+
weight: Optional[int] = None
|
|
53
|
+
flags: Optional[int] = None
|
|
54
|
+
tag: Optional[str] = None
|
|
55
|
+
id: Optional[int] = field(default=None, compare=False)
|
|
56
|
+
|
|
57
|
+
def __post_init__(self) -> None:
|
|
58
|
+
self.type = record_type(self.type)
|
|
59
|
+
self.name = self.name.strip() or "@"
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def a(cls, name: str, ip: str, ttl: Optional[int] = None) -> DNSRecord:
|
|
63
|
+
return cls("A", name, str(ipaddress.IPv4Address(ip)), ttl)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def aaaa(cls, name: str, ip: str, ttl: Optional[int] = None) -> DNSRecord:
|
|
67
|
+
return cls("AAAA", name, str(ipaddress.IPv6Address(ip)), ttl)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def cname(cls, name: str, target: str, ttl: Optional[int] = None) -> DNSRecord:
|
|
71
|
+
return cls("CNAME", name, hostname(target), ttl)
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def txt(cls, name: str, value: str, ttl: Optional[int] = None) -> DNSRecord:
|
|
75
|
+
return cls("TXT", name, value, ttl)
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def mx(cls, name: str, host: str, priority: int = 10, ttl: Optional[int] = None) -> DNSRecord:
|
|
79
|
+
return cls("MX", name, hostname(host), ttl, priority=priority)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def ns(cls, name: str, host: str, ttl: Optional[int] = None) -> DNSRecord:
|
|
83
|
+
return cls("NS", name, hostname(host), ttl)
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def srv(
|
|
87
|
+
cls, name: str, target: str, port: int, priority: int = 10, weight: int = 5, ttl: Optional[int] = None
|
|
88
|
+
) -> DNSRecord:
|
|
89
|
+
return cls("SRV", name, hostname(target), ttl, priority=priority, port=port, weight=weight)
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def caa(cls, name: str, value: str, tag: str = "issue", flags: int = 0, ttl: Optional[int] = None) -> DNSRecord:
|
|
93
|
+
return cls("CAA", name, value, ttl, flags=flags, tag=tag)
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def from_api(cls, data: dict[str, Any]) -> DNSRecord:
|
|
97
|
+
return cls(
|
|
98
|
+
type=str(data.get("type", "")),
|
|
99
|
+
name=str(data.get("name", "@")),
|
|
100
|
+
data=str(data.get("data", "")),
|
|
101
|
+
ttl=data.get("ttl"),
|
|
102
|
+
priority=data.get("priority"),
|
|
103
|
+
port=data.get("port"),
|
|
104
|
+
weight=data.get("weight"),
|
|
105
|
+
flags=data.get("flags"),
|
|
106
|
+
tag=data.get("tag"),
|
|
107
|
+
id=data.get("id"),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def to_api(self, default_ttl: Optional[int] = None) -> dict[str, Any]:
|
|
111
|
+
body: dict[str, Any] = {"type": self.type, "name": self.name, "data": self.data}
|
|
112
|
+
ttl = self.ttl if self.ttl is not None else default_ttl
|
|
113
|
+
if ttl is not None:
|
|
114
|
+
body["ttl"] = ttl
|
|
115
|
+
for key in ("priority", "port", "weight", "flags", "tag"):
|
|
116
|
+
value = getattr(self, key)
|
|
117
|
+
if value is not None:
|
|
118
|
+
body[key] = value
|
|
119
|
+
return body
|
|
120
|
+
|
|
121
|
+
def fqdn(self, domain: str) -> str:
|
|
122
|
+
return domain if self.name == "@" else f"{self.name}.{domain}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class Domain:
|
|
127
|
+
name: str
|
|
128
|
+
ttl: Optional[int] = None
|
|
129
|
+
zone_file: Optional[str] = None
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def from_api(cls, data: dict[str, Any]) -> Domain:
|
|
133
|
+
return cls(name=str(data.get("name", "")), ttl=data.get("ttl"), zone_file=data.get("zone_file"))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class Account:
|
|
138
|
+
email: str
|
|
139
|
+
uuid: str
|
|
140
|
+
status: str
|
|
141
|
+
email_verified: bool = False
|
|
142
|
+
droplet_limit: Optional[int] = None
|
|
143
|
+
floating_ip_limit: Optional[int] = None
|
|
144
|
+
team: Optional[str] = None
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def from_api(cls, data: dict[str, Any]) -> Account:
|
|
148
|
+
team = data.get("team") if isinstance(data.get("team"), dict) else {}
|
|
149
|
+
return cls(
|
|
150
|
+
email=str(data.get("email", "")),
|
|
151
|
+
uuid=str(data.get("uuid", "")),
|
|
152
|
+
status=str(data.get("status", "")),
|
|
153
|
+
email_verified=bool(data.get("email_verified", False)),
|
|
154
|
+
droplet_limit=data.get("droplet_limit"),
|
|
155
|
+
floating_ip_limit=data.get("floating_ip_limit"),
|
|
156
|
+
team=team.get("name") if team else None,
|
|
157
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Mapping, Optional
|
|
4
|
+
|
|
5
|
+
from .._base import Call, Flow
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def has_next(body: Any) -> bool:
|
|
9
|
+
if not isinstance(body, dict):
|
|
10
|
+
return False
|
|
11
|
+
pages = (body.get("links") or {}).get("pages") or {}
|
|
12
|
+
return bool(pages.get("next"))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def page_call(path: str, page: int, size: int, params: Optional[Mapping[str, Any]] = None) -> Call:
|
|
16
|
+
return Call("GET", path, params={**(params or {}), "page": page, "per_page": size})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def items_of(body: Any, key: str, parse: Callable[[dict[str, Any]], Any]) -> list[Any]:
|
|
20
|
+
rows = body.get(key) if isinstance(body, dict) else None
|
|
21
|
+
return [parse(row) for row in rows or [] if isinstance(row, dict)]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def collect(
|
|
25
|
+
path: str, key: str, parse: Callable[[dict[str, Any]], Any], size: int, params: Optional[Mapping[str, Any]] = None
|
|
26
|
+
) -> Flow[list[Any]]:
|
|
27
|
+
found: list[Any] = []
|
|
28
|
+
page = 1
|
|
29
|
+
while True:
|
|
30
|
+
body = yield page_call(path, page, size, params)
|
|
31
|
+
batch = items_of(body, key, parse)
|
|
32
|
+
found.extend(batch)
|
|
33
|
+
if not batch or not has_next(body):
|
|
34
|
+
return found
|
|
35
|
+
page += 1
|