hostinger-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.
- hostinger_python_sdk-0.1.0.dist-info/METADATA +366 -0
- hostinger_python_sdk-0.1.0.dist-info/RECORD +17 -0
- hostinger_python_sdk-0.1.0.dist-info/WHEEL +4 -0
- hostinger_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- hostinger_sdk/__init__.py +47 -0
- hostinger_sdk/_base.py +246 -0
- hostinger_sdk/_version.py +1 -0
- hostinger_sdk/client.py +20 -0
- hostinger_sdk/config.py +75 -0
- hostinger_sdk/errors.py +151 -0
- hostinger_sdk/messages.py +63 -0
- hostinger_sdk/messages.yaml +28 -0
- hostinger_sdk/models.py +252 -0
- hostinger_sdk/py.typed +0 -0
- hostinger_sdk/resources/__init__.py +4 -0
- hostinger_sdk/resources/dns.py +196 -0
- hostinger_sdk/resources/domains.py +97 -0
hostinger_sdk/_base.py
ADDED
|
@@ -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, api_token=token, **overrides)
|
|
65
|
+
elif token or overrides:
|
|
66
|
+
changes = {k: v for k, v in dict(overrides, api_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"
|
hostinger_sdk/client.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ._base import AsyncClient, SyncClient
|
|
6
|
+
from .resources import DNS, AsyncDNS, AsyncDomains, Domains
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Hostinger(SyncClient):
|
|
10
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
11
|
+
super().__init__(*args, **kwargs)
|
|
12
|
+
self.domains = Domains(self)
|
|
13
|
+
self.dns = DNS(self)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AsyncHostinger(AsyncClient):
|
|
17
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
18
|
+
super().__init__(*args, **kwargs)
|
|
19
|
+
self.domains = AsyncDomains(self)
|
|
20
|
+
self.dns = AsyncDNS(self)
|
hostinger_sdk/config.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
zone: str = "/api/dns/{version}/zones/{domain}"
|
|
16
|
+
zone_reset: str = "/api/dns/{version}/zones/{domain}/reset"
|
|
17
|
+
zone_validate: str = "/api/dns/{version}/zones/{domain}/validate"
|
|
18
|
+
snapshots: str = "/api/dns/{version}/snapshots/{domain}"
|
|
19
|
+
snapshot: str = "/api/dns/{version}/snapshots/{domain}/{snapshot_id}"
|
|
20
|
+
snapshot_restore: str = "/api/dns/{version}/snapshots/{domain}/{snapshot_id}/restore"
|
|
21
|
+
domains: str = "/api/domains/{version}/portfolio"
|
|
22
|
+
domain: str = "/api/domains/{version}/portfolio/{domain}"
|
|
23
|
+
availability: str = "/api/domains/{version}/availability"
|
|
24
|
+
nameservers: str = "/api/domains/{version}/portfolio/{domain}/nameservers"
|
|
25
|
+
domain_lock: str = "/api/domains/{version}/portfolio/{domain}/domain-lock"
|
|
26
|
+
privacy: str = "/api/domains/{version}/portfolio/{domain}/privacy-protection"
|
|
27
|
+
forwardings: str = "/api/domains/{version}/forwarding"
|
|
28
|
+
forwarding: str = "/api/domains/{version}/forwarding/{domain}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Settings(GoodConf):
|
|
32
|
+
model_config = GoodConfConfigDict(
|
|
33
|
+
env_prefix="HOSTINGER_",
|
|
34
|
+
file_env_var="HOSTINGER_CONFIG",
|
|
35
|
+
default_files=["hostinger.toml", "hostinger.yaml", "hostinger.yml", "hostinger.json"],
|
|
36
|
+
extra="ignore",
|
|
37
|
+
env_nested_delimiter="__",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
api_token: SecretStr = Field(default=SecretStr(""), description=text("settings.api_token"))
|
|
41
|
+
base_url: str = Field(default="https://developers.hostinger.com", description=text("settings.base_url"))
|
|
42
|
+
api_version: str = Field(default="v1", description=text("settings.api_version"))
|
|
43
|
+
endpoints: Endpoints = Field(default_factory=Endpoints, description=text("settings.endpoints"))
|
|
44
|
+
timeout: float = Field(default=30.0, gt=0, description=text("settings.timeout"))
|
|
45
|
+
max_retries: int = Field(default=3, ge=0, description=text("settings.max_retries"))
|
|
46
|
+
max_retry_wait: float = Field(default=60.0, ge=0, description=text("settings.max_retry_wait"))
|
|
47
|
+
backoff_base: float = Field(default=0.5, ge=0, description=text("settings.backoff_base"))
|
|
48
|
+
backoff_cap: float = Field(default=8.0, ge=0, description=text("settings.backoff_cap"))
|
|
49
|
+
default_ttl: int = Field(default=14400, ge=60, description=text("settings.default_ttl"))
|
|
50
|
+
user_agent: str = Field(default="hostinger-python-sdk/{version}", description=text("settings.user_agent"))
|
|
51
|
+
|
|
52
|
+
def resolved_base_url(self) -> str:
|
|
53
|
+
return self.base_url.rstrip("/")
|
|
54
|
+
|
|
55
|
+
def path(self, endpoint: str, **params: Any) -> str:
|
|
56
|
+
template = getattr(self.endpoints, endpoint)
|
|
57
|
+
values = {key: quote(str(value), safe="@") for key, value in params.items()}
|
|
58
|
+
return template.format(version=quote(self.api_version, safe=""), **values)
|
|
59
|
+
|
|
60
|
+
def resolved_user_agent(self) -> str:
|
|
61
|
+
return self.user_agent.replace("{version}", __version__)
|
|
62
|
+
|
|
63
|
+
def require_token(self) -> str:
|
|
64
|
+
token = self.api_token.get_secret_value().strip()
|
|
65
|
+
if not token:
|
|
66
|
+
raise AuthenticationError(text("error.missing_token"))
|
|
67
|
+
return token
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
Settings.__doc__ = text("settings.description")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_settings(config_file: Optional[str] = None, **overrides: Any) -> Settings:
|
|
74
|
+
given = {key: value for key, value in overrides.items() if value is not None}
|
|
75
|
+
return Settings(load=True, config_file=config_file, **given)
|
hostinger_sdk/errors.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .messages import text
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class FieldError:
|
|
14
|
+
field: str
|
|
15
|
+
messages: tuple[str, ...]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HostingerError(Exception):
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
message: str,
|
|
22
|
+
*,
|
|
23
|
+
status_code: Optional[int] = None,
|
|
24
|
+
correlation_id: Optional[str] = None,
|
|
25
|
+
fields: Optional[list[FieldError]] = None,
|
|
26
|
+
response: Optional[httpx.Response] = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.message = message
|
|
30
|
+
self.status_code = status_code
|
|
31
|
+
self.correlation_id = correlation_id
|
|
32
|
+
self.fields = fields or []
|
|
33
|
+
self.response = response
|
|
34
|
+
|
|
35
|
+
def __str__(self) -> str:
|
|
36
|
+
parts = []
|
|
37
|
+
if self.status_code:
|
|
38
|
+
parts.append(f"[{self.status_code}]")
|
|
39
|
+
parts.append(self.message)
|
|
40
|
+
if self.fields:
|
|
41
|
+
parts.append("(" + "; ".join(f"{f.field}: {' '.join(f.messages)}" for f in self.fields) + ")")
|
|
42
|
+
if self.correlation_id:
|
|
43
|
+
parts.append(f"[correlation {self.correlation_id}]")
|
|
44
|
+
return " ".join(parts)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class APIConnectionError(HostingerError):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class APITimeoutError(APIConnectionError):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class APIStatusError(HostingerError):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ValidationError(APIStatusError):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AuthenticationError(APIStatusError):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class PermissionDeniedError(APIStatusError):
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class NotFoundError(APIStatusError):
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ConflictError(APIStatusError):
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class RateLimitError(APIStatusError):
|
|
80
|
+
def __init__(self, message: str, *, retry_after: Optional[float] = None, **kwargs: Any) -> None:
|
|
81
|
+
super().__init__(message, **kwargs)
|
|
82
|
+
self.retry_after = retry_after
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ServerError(APIStatusError):
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_STATUS_MAP: dict[int, type[APIStatusError]] = {
|
|
90
|
+
400: ValidationError,
|
|
91
|
+
401: AuthenticationError,
|
|
92
|
+
403: PermissionDeniedError,
|
|
93
|
+
404: NotFoundError,
|
|
94
|
+
409: ConflictError,
|
|
95
|
+
422: ValidationError,
|
|
96
|
+
429: RateLimitError,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def retry_after_from(response: httpx.Response, now: Optional[float] = None) -> Optional[float]:
|
|
101
|
+
header = response.headers.get("Retry-After")
|
|
102
|
+
if header:
|
|
103
|
+
try:
|
|
104
|
+
return max(0.0, float(header))
|
|
105
|
+
except ValueError:
|
|
106
|
+
pass
|
|
107
|
+
reset = response.headers.get("X-RateLimit-Reset")
|
|
108
|
+
if reset:
|
|
109
|
+
try:
|
|
110
|
+
value = float(reset)
|
|
111
|
+
except ValueError:
|
|
112
|
+
return None
|
|
113
|
+
current = now if now is not None else time.time()
|
|
114
|
+
return max(0.0, value - current) if value > 1_000_000_000 else max(0.0, value)
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _fields(body: Any) -> list[FieldError]:
|
|
119
|
+
errors = body.get("errors") if isinstance(body, dict) else None
|
|
120
|
+
if not isinstance(errors, dict):
|
|
121
|
+
return []
|
|
122
|
+
found = []
|
|
123
|
+
for name, messages in errors.items():
|
|
124
|
+
items = messages if isinstance(messages, list) else [messages]
|
|
125
|
+
found.append(FieldError(str(name), tuple(str(m) for m in items)))
|
|
126
|
+
return found
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def error_from_response(response: httpx.Response) -> APIStatusError:
|
|
130
|
+
try:
|
|
131
|
+
body = response.json()
|
|
132
|
+
except ValueError:
|
|
133
|
+
body = None
|
|
134
|
+
|
|
135
|
+
if isinstance(body, dict):
|
|
136
|
+
found = body.get("message") or body.get("error")
|
|
137
|
+
message = str(found or response.reason_phrase or text("error.request_failed"))
|
|
138
|
+
correlation_id = body.get("correlation_id")
|
|
139
|
+
else:
|
|
140
|
+
message = response.text.strip() or response.reason_phrase or text("error.request_failed")
|
|
141
|
+
correlation_id = None
|
|
142
|
+
|
|
143
|
+
status = response.status_code
|
|
144
|
+
kwargs: dict[str, Any] = dict(status_code=status, correlation_id=correlation_id, fields=_fields(body),
|
|
145
|
+
response=response)
|
|
146
|
+
|
|
147
|
+
if status == 429:
|
|
148
|
+
return RateLimitError(message, retry_after=retry_after_from(response), **kwargs)
|
|
149
|
+
if status >= 500:
|
|
150
|
+
return ServerError(message, **kwargs)
|
|
151
|
+
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 Hostinger API client.
|
|
3
|
+
api_token: API token from hpanel.hostinger.com/profile/api.
|
|
4
|
+
base_url: API URL without any path.
|
|
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
|
+
default_ttl: TTL in seconds used for new records when none is given.
|
|
13
|
+
user_agent: User-Agent header. {version} is replaced with the package version.
|
|
14
|
+
error:
|
|
15
|
+
request_failed: Request failed
|
|
16
|
+
missing_token: Missing token. Pass token, set HOSTINGER_API_TOKEN, or point HOSTINGER_CONFIG at a config file.
|
|
17
|
+
timeout: 'Request timed out: {error}'
|
|
18
|
+
connection: 'Connection failed: {error}'
|
|
19
|
+
value:
|
|
20
|
+
empty_domain: domain must not be empty
|
|
21
|
+
empty_name: record name must not be empty, use '@' for the apex
|
|
22
|
+
empty_type: record type must not be empty
|
|
23
|
+
no_values: at least one record value is required
|
|
24
|
+
add_needs_records: add() needs at least one DNSRecord
|
|
25
|
+
foreign_name: '{name!r} is not inside {domain}'
|
|
26
|
+
nameserver_count: a domain needs between 2 and 4 name servers, got {count}
|
|
27
|
+
no_tlds: available() needs at least one TLD
|
|
28
|
+
redirect_type: redirect type must be 301 or 302, got {value!r}
|