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.
@@ -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,4 @@
1
+ from .dns import DNS, AsyncDNS, fqdn, relative_name
2
+ from .domains import AsyncDomains, Domains
3
+
4
+ __all__ = ["DNS", "AsyncDNS", "Domains", "AsyncDomains", "fqdn", "relative_name"]
@@ -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