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_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"
@@ -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)
@@ -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)
@@ -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}