namecheap-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.
- namecheap_python_sdk-0.1.0.dist-info/METADATA +354 -0
- namecheap_python_sdk-0.1.0.dist-info/RECORD +17 -0
- namecheap_python_sdk-0.1.0.dist-info/WHEEL +4 -0
- namecheap_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- namecheap_sdk/__init__.py +47 -0
- namecheap_sdk/_base.py +226 -0
- namecheap_sdk/_version.py +1 -0
- namecheap_sdk/client.py +29 -0
- namecheap_sdk/config.py +90 -0
- namecheap_sdk/errors.py +133 -0
- namecheap_sdk/messages.py +63 -0
- namecheap_sdk/messages.yaml +39 -0
- namecheap_sdk/models.py +336 -0
- namecheap_sdk/py.typed +0 -0
- namecheap_sdk/resources/__init__.py +4 -0
- namecheap_sdk/resources/dns.py +222 -0
- namecheap_sdk/resources/domains.py +136 -0
namecheap_sdk/client.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ._base import AsyncClient, Call, SyncClient
|
|
6
|
+
from .models import Balances
|
|
7
|
+
from .resources import DNS, AsyncDNS, AsyncDomains, Domains
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Namecheap(SyncClient):
|
|
11
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
12
|
+
super().__init__(*args, **kwargs)
|
|
13
|
+
self.domains = Domains(self)
|
|
14
|
+
self.dns = DNS(self)
|
|
15
|
+
|
|
16
|
+
def balances(self) -> Balances:
|
|
17
|
+
result: Balances = self._execute(Call(self.settings.command("balances"), parse=Balances.from_xml))
|
|
18
|
+
return result
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AsyncNamecheap(AsyncClient):
|
|
22
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
23
|
+
super().__init__(*args, **kwargs)
|
|
24
|
+
self.domains = AsyncDomains(self)
|
|
25
|
+
self.dns = AsyncDNS(self)
|
|
26
|
+
|
|
27
|
+
async def balances(self) -> Balances:
|
|
28
|
+
result: Balances = await self._execute(Call(self.settings.command("balances"), parse=Balances.from_xml))
|
|
29
|
+
return result
|
namecheap_sdk/config.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
from goodconf import Field, GoodConf, GoodConfConfigDict
|
|
6
|
+
from pydantic import BaseModel, SecretStr
|
|
7
|
+
|
|
8
|
+
from ._version import __version__
|
|
9
|
+
from .errors import AuthenticationError
|
|
10
|
+
from .messages import text
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Commands(BaseModel):
|
|
14
|
+
get_hosts: str = "namecheap.domains.dns.getHosts"
|
|
15
|
+
set_hosts: str = "namecheap.domains.dns.setHosts"
|
|
16
|
+
get_nameservers: str = "namecheap.domains.dns.getList"
|
|
17
|
+
set_custom_nameservers: str = "namecheap.domains.dns.setCustom"
|
|
18
|
+
set_default_nameservers: str = "namecheap.domains.dns.setDefault"
|
|
19
|
+
list_domains: str = "namecheap.domains.getList"
|
|
20
|
+
domain_info: str = "namecheap.domains.getInfo"
|
|
21
|
+
check: str = "namecheap.domains.check"
|
|
22
|
+
tlds: str = "namecheap.domains.getTldList"
|
|
23
|
+
get_lock: str = "namecheap.domains.getRegistrarLock"
|
|
24
|
+
set_lock: str = "namecheap.domains.setRegistrarLock"
|
|
25
|
+
balances: str = "namecheap.users.getBalances"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Settings(GoodConf):
|
|
29
|
+
model_config = GoodConfConfigDict(
|
|
30
|
+
env_prefix="NAMECHEAP_",
|
|
31
|
+
file_env_var="NAMECHEAP_CONFIG",
|
|
32
|
+
default_files=["namecheap.toml", "namecheap.yaml", "namecheap.yml", "namecheap.json"],
|
|
33
|
+
extra="ignore",
|
|
34
|
+
env_nested_delimiter="__",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
api_user: str = Field(default="", description=text("settings.api_user"))
|
|
38
|
+
api_key: SecretStr = Field(default=SecretStr(""), description=text("settings.api_key"))
|
|
39
|
+
username: Optional[str] = Field(default=None, description=text("settings.username"))
|
|
40
|
+
client_ip: str = Field(default="", description=text("settings.client_ip"))
|
|
41
|
+
environment: str = Field(default="production", description=text("settings.environment"))
|
|
42
|
+
base_url: Optional[str] = Field(default=None, description=text("settings.base_url"))
|
|
43
|
+
production_url: str = Field(default="https://api.namecheap.com/xml.response",
|
|
44
|
+
description=text("settings.production_url"))
|
|
45
|
+
sandbox_url: str = Field(default="https://api.sandbox.namecheap.com/xml.response",
|
|
46
|
+
description=text("settings.sandbox_url"))
|
|
47
|
+
commands: Commands = Field(default_factory=Commands, description=text("settings.commands"))
|
|
48
|
+
timeout: float = Field(default=30.0, gt=0, description=text("settings.timeout"))
|
|
49
|
+
max_retries: int = Field(default=3, ge=0, description=text("settings.max_retries"))
|
|
50
|
+
max_retry_wait: float = Field(default=60.0, ge=0, description=text("settings.max_retry_wait"))
|
|
51
|
+
backoff_base: float = Field(default=1.0, ge=0, description=text("settings.backoff_base"))
|
|
52
|
+
backoff_cap: float = Field(default=16.0, ge=0, description=text("settings.backoff_cap"))
|
|
53
|
+
page_size: int = Field(default=100, ge=10, le=100, description=text("settings.page_size"))
|
|
54
|
+
default_ttl: int = Field(default=1800, ge=60, le=60000, description=text("settings.default_ttl"))
|
|
55
|
+
user_agent: str = Field(default="namecheap-python-sdk/{version}", description=text("settings.user_agent"))
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def environments(self) -> dict[str, str]:
|
|
59
|
+
return {"production": self.production_url, "prod": self.production_url,
|
|
60
|
+
"sandbox": self.sandbox_url, "test": self.sandbox_url}
|
|
61
|
+
|
|
62
|
+
def resolved_base_url(self) -> str:
|
|
63
|
+
if self.base_url:
|
|
64
|
+
return self.base_url
|
|
65
|
+
env = self.environment.strip().lower()
|
|
66
|
+
if env not in self.environments:
|
|
67
|
+
raise ValueError(text("error.unknown_environment", environment=env, choices="production, sandbox"))
|
|
68
|
+
return self.environments[env]
|
|
69
|
+
|
|
70
|
+
def command(self, name: str) -> str:
|
|
71
|
+
return str(getattr(self.commands, name))
|
|
72
|
+
|
|
73
|
+
def resolved_user_agent(self) -> str:
|
|
74
|
+
return self.user_agent.replace("{version}", __version__)
|
|
75
|
+
|
|
76
|
+
def require_credentials(self) -> dict[str, str]:
|
|
77
|
+
user = self.api_user.strip()
|
|
78
|
+
key = self.api_key.get_secret_value().strip()
|
|
79
|
+
ip = self.client_ip.strip()
|
|
80
|
+
if not user or not key or not ip:
|
|
81
|
+
raise AuthenticationError(text("error.missing_credentials"))
|
|
82
|
+
return {"ApiUser": user, "ApiKey": key, "UserName": (self.username or user).strip(), "ClientIp": ip}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
Settings.__doc__ = text("settings.description")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def load_settings(config_file: Optional[str] = None, **overrides: Any) -> Settings:
|
|
89
|
+
given = {key: value for key, value in overrides.items() if value is not None}
|
|
90
|
+
return Settings(load=True, config_file=config_file, **given)
|
namecheap_sdk/errors.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class APIErrorDetail:
|
|
11
|
+
number: str
|
|
12
|
+
message: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NamecheapError(Exception):
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
message: str,
|
|
19
|
+
*,
|
|
20
|
+
status_code: Optional[int] = None,
|
|
21
|
+
number: Optional[str] = None,
|
|
22
|
+
errors: Optional[list[APIErrorDetail]] = None,
|
|
23
|
+
command: Optional[str] = None,
|
|
24
|
+
response: Optional[httpx.Response] = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.message = message
|
|
28
|
+
self.status_code = status_code
|
|
29
|
+
self.number = number
|
|
30
|
+
self.errors = errors or []
|
|
31
|
+
self.command = command
|
|
32
|
+
self.response = response
|
|
33
|
+
|
|
34
|
+
def __str__(self) -> str:
|
|
35
|
+
parts = []
|
|
36
|
+
if self.status_code and self.status_code != 200:
|
|
37
|
+
parts.append(f"[{self.status_code}]")
|
|
38
|
+
if self.number:
|
|
39
|
+
parts.append(self.number)
|
|
40
|
+
parts.append(self.message)
|
|
41
|
+
if self.command:
|
|
42
|
+
parts.append(f"({self.command})")
|
|
43
|
+
return " ".join(parts)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class APIConnectionError(NamecheapError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class APITimeoutError(APIConnectionError):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class APIError(NamecheapError):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ValidationError(APIError):
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AuthenticationError(APIError):
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class PermissionDeniedError(APIError):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class NotFoundError(APIError):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class RateLimitError(APIError):
|
|
75
|
+
def __init__(self, message: str, *, retry_after: Optional[float] = None, **kwargs: Any) -> None:
|
|
76
|
+
super().__init__(message, **kwargs)
|
|
77
|
+
self.retry_after = retry_after
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ServerError(APIError):
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
AUTH_NUMBERS = frozenset({
|
|
85
|
+
"1010101", "1010102", "1011102", "1010104", "1010105", "1011105", "1030408", "1050900", "1011150",
|
|
86
|
+
"1017150", "1017105", "1017101", "1017410", "1017411", "1019103", "1016103", "1017103",
|
|
87
|
+
})
|
|
88
|
+
NOT_FOUND_NUMBERS = frozenset({"2019166", "2016166"})
|
|
89
|
+
PERMISSION_NUMBERS = frozenset({"2030166", "4011103"})
|
|
90
|
+
VALIDATION_NUMBERS = frozenset({"3013288", "4013288", "2015278", "3011288"})
|
|
91
|
+
SERVER_NUMBERS = frozenset({"3031510", "3050900", "5050900", "5019169", "5050169", "4022288", "4023330"})
|
|
92
|
+
STATUS_MAP: dict[int, type[APIError]] = {401: AuthenticationError, 403: PermissionDeniedError, 404: NotFoundError}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def error_class(number: str, message: str) -> type[APIError]:
|
|
96
|
+
lowered = message.lower()
|
|
97
|
+
if "too many requests" in lowered or "rate limit" in lowered:
|
|
98
|
+
return RateLimitError
|
|
99
|
+
if number in AUTH_NUMBERS:
|
|
100
|
+
return AuthenticationError
|
|
101
|
+
if number in NOT_FOUND_NUMBERS:
|
|
102
|
+
return NotFoundError
|
|
103
|
+
if number in PERMISSION_NUMBERS:
|
|
104
|
+
return PermissionDeniedError
|
|
105
|
+
if number in VALIDATION_NUMBERS:
|
|
106
|
+
return ValidationError
|
|
107
|
+
if number in SERVER_NUMBERS or number.startswith("5"):
|
|
108
|
+
return ServerError
|
|
109
|
+
if number.startswith("2"):
|
|
110
|
+
return ValidationError
|
|
111
|
+
return APIError
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def from_details(details: list[APIErrorDetail], command: str, response: Optional[httpx.Response] = None) -> APIError:
|
|
115
|
+
first = details[0] if details else APIErrorDetail("", "")
|
|
116
|
+
cls = error_class(first.number, first.message)
|
|
117
|
+
message = "; ".join(d.message for d in details if d.message) or first.message
|
|
118
|
+
return cls(message, number=first.number or None, errors=details, command=command,
|
|
119
|
+
status_code=response.status_code if response is not None else None, response=response)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def from_status(response: httpx.Response, command: str) -> APIError:
|
|
123
|
+
status = response.status_code
|
|
124
|
+
message = response.reason_phrase or response.text.strip()[:200]
|
|
125
|
+
if status == 429:
|
|
126
|
+
header = response.headers.get("Retry-After")
|
|
127
|
+
try:
|
|
128
|
+
wait = float(header) if header else None
|
|
129
|
+
except ValueError:
|
|
130
|
+
wait = None
|
|
131
|
+
return RateLimitError(message, retry_after=wait, status_code=status, command=command, response=response)
|
|
132
|
+
cls = ServerError if status >= 500 else STATUS_MAP.get(status, APIError)
|
|
133
|
+
return cls(message, status_code=status, command=command, response=response)
|
|
@@ -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,39 @@
|
|
|
1
|
+
settings:
|
|
2
|
+
description: Settings for the Namecheap API client.
|
|
3
|
+
api_user: Account name that owns the API key.
|
|
4
|
+
api_key: API key from Profile, Tools, Namecheap API Access.
|
|
5
|
+
username: Account the commands run on. Defaults to api_user.
|
|
6
|
+
client_ip: Whitelisted IPv4 address of the machine that makes the calls.
|
|
7
|
+
environment: 'Which Namecheap environment to call: production or sandbox.'
|
|
8
|
+
base_url: Full API URL. Overrides the environment when set.
|
|
9
|
+
production_url: API URL used for the production environment.
|
|
10
|
+
sandbox_url: API URL used for the sandbox environment.
|
|
11
|
+
commands: Command names sent for every call.
|
|
12
|
+
timeout: Seconds to wait for a single request.
|
|
13
|
+
max_retries: How many times a failed request is tried again.
|
|
14
|
+
max_retry_wait: Longest wait in seconds the client accepts between retries.
|
|
15
|
+
backoff_base: First backoff delay in seconds, doubled on every retry.
|
|
16
|
+
backoff_cap: Longest backoff delay in seconds.
|
|
17
|
+
page_size: Domains asked for on every page when listing, between 10 and 100.
|
|
18
|
+
default_ttl: TTL in seconds used for new records when none is given.
|
|
19
|
+
user_agent: User-Agent header. {version} is replaced with the package version.
|
|
20
|
+
error:
|
|
21
|
+
request_failed: Request failed
|
|
22
|
+
missing_credentials: Missing credentials. Pass api_user, api_key and client_ip, set NAMECHEAP_API_USER,
|
|
23
|
+
NAMECHEAP_API_KEY and NAMECHEAP_CLIENT_IP, or point NAMECHEAP_CONFIG at a config file.
|
|
24
|
+
unknown_environment: 'Unknown environment {environment!r}. Use one of: {choices}.'
|
|
25
|
+
timeout: 'Request timed out: {error}'
|
|
26
|
+
connection: 'Connection failed: {error}'
|
|
27
|
+
not_saved: Namecheap did not confirm the change for {domain}
|
|
28
|
+
bad_xml: 'Namecheap answered with something that is not valid XML: {error}'
|
|
29
|
+
value:
|
|
30
|
+
empty_domain: domain must not be empty
|
|
31
|
+
no_tld: '{domain!r} has no TLD, pass a full domain like example.com'
|
|
32
|
+
empty_name: record name must not be empty, use '@' for the apex
|
|
33
|
+
empty_type: record type must not be empty
|
|
34
|
+
no_values: at least one record value is required
|
|
35
|
+
add_needs_records: add() needs at least one DNSRecord
|
|
36
|
+
foreign_name: '{name!r} is not inside {domain}'
|
|
37
|
+
ttl_range: TTL must be between 60 and 60000 seconds, got {ttl}
|
|
38
|
+
no_domains: available_many() needs at least one domain
|
|
39
|
+
nameserver_count: a domain needs at least 2 name servers, got {count}
|
namecheap_sdk/models.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import xml.etree.ElementTree as ET
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from decimal import Decimal, InvalidOperation
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Any, Optional, Union
|
|
10
|
+
|
|
11
|
+
from .messages import text
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RecordType(str, Enum):
|
|
15
|
+
A = "A"
|
|
16
|
+
AAAA = "AAAA"
|
|
17
|
+
ALIAS = "ALIAS"
|
|
18
|
+
CAA = "CAA"
|
|
19
|
+
CNAME = "CNAME"
|
|
20
|
+
MX = "MX"
|
|
21
|
+
MXE = "MXE"
|
|
22
|
+
NS = "NS"
|
|
23
|
+
TXT = "TXT"
|
|
24
|
+
URL = "URL"
|
|
25
|
+
URL301 = "URL301"
|
|
26
|
+
FRAME = "FRAME"
|
|
27
|
+
|
|
28
|
+
def __str__(self) -> str:
|
|
29
|
+
return self.value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
RecordTypeLike = Union[RecordType, str]
|
|
33
|
+
HOST_TYPES = frozenset({"CNAME", "ALIAS", "NS", "MX"})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def record_type(value: RecordTypeLike) -> str:
|
|
37
|
+
kind = value.value if isinstance(value, RecordType) else str(value).strip().upper()
|
|
38
|
+
if not kind:
|
|
39
|
+
raise ValueError(text("value.empty_type"))
|
|
40
|
+
return kind
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def hostname(value: str) -> str:
|
|
44
|
+
host = value.strip()
|
|
45
|
+
if host == "@" or host.endswith(".") or "." not in host:
|
|
46
|
+
return host
|
|
47
|
+
return f"{host}."
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def flag(value: Any) -> bool:
|
|
51
|
+
return str(value).strip().lower() == "true"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def number(value: Any) -> Optional[int]:
|
|
55
|
+
try:
|
|
56
|
+
return int(str(value).strip())
|
|
57
|
+
except (TypeError, ValueError):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def money(value: Any) -> Optional[Decimal]:
|
|
62
|
+
if value in (None, ""):
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
return Decimal(str(value))
|
|
66
|
+
except InvalidOperation:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def day(value: Any) -> Optional[date]:
|
|
71
|
+
if not value:
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
return datetime.strptime(str(value).strip(), "%m/%d/%Y").date()
|
|
75
|
+
except ValueError:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def child_text(element: Optional[ET.Element], path: str) -> Optional[str]:
|
|
80
|
+
if element is None:
|
|
81
|
+
return None
|
|
82
|
+
found = element.find(path)
|
|
83
|
+
return found.text.strip() if found is not None and found.text else None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class DNSRecord:
|
|
88
|
+
type: str
|
|
89
|
+
name: str
|
|
90
|
+
data: str
|
|
91
|
+
ttl: Optional[int] = None
|
|
92
|
+
priority: Optional[int] = None
|
|
93
|
+
id: Optional[int] = field(default=None, compare=False)
|
|
94
|
+
active: bool = field(default=True, compare=False)
|
|
95
|
+
|
|
96
|
+
def __post_init__(self) -> None:
|
|
97
|
+
self.type = record_type(self.type)
|
|
98
|
+
self.name = self.name.strip() or "@"
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def a(cls, name: str, ip: str, ttl: Optional[int] = None) -> DNSRecord:
|
|
102
|
+
return cls("A", name, str(ipaddress.IPv4Address(ip)), ttl)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def aaaa(cls, name: str, ip: str, ttl: Optional[int] = None) -> DNSRecord:
|
|
106
|
+
return cls("AAAA", name, str(ipaddress.IPv6Address(ip)), ttl)
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def cname(cls, name: str, target: str, ttl: Optional[int] = None) -> DNSRecord:
|
|
110
|
+
return cls("CNAME", name, hostname(target), ttl)
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def alias(cls, name: str, target: str, ttl: Optional[int] = None) -> DNSRecord:
|
|
114
|
+
return cls("ALIAS", name, hostname(target), ttl)
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def txt(cls, name: str, value: str, ttl: Optional[int] = None) -> DNSRecord:
|
|
118
|
+
return cls("TXT", name, value, ttl)
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def mx(cls, name: str, host: str, priority: int = 10, ttl: Optional[int] = None) -> DNSRecord:
|
|
122
|
+
return cls("MX", name, hostname(host), ttl, priority=priority)
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def ns(cls, name: str, host: str, ttl: Optional[int] = None) -> DNSRecord:
|
|
126
|
+
return cls("NS", name, hostname(host), ttl)
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def caa(cls, name: str, value: str, tag: str = "issue", flags: int = 0, ttl: Optional[int] = None) -> DNSRecord:
|
|
130
|
+
return cls("CAA", name, f'{flags} {tag} "{value}"', ttl)
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def url(cls, name: str, target: str, *, permanent: bool = True, ttl: Optional[int] = None) -> DNSRecord:
|
|
134
|
+
return cls("URL301" if permanent else "URL", name, target, ttl)
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def frame(cls, name: str, target: str, ttl: Optional[int] = None) -> DNSRecord:
|
|
138
|
+
return cls("FRAME", name, target, ttl)
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def from_xml(cls, element: ET.Element) -> DNSRecord:
|
|
142
|
+
kind = element.get("Type", "")
|
|
143
|
+
return cls(
|
|
144
|
+
type=kind,
|
|
145
|
+
name=element.get("Name", "@"),
|
|
146
|
+
data=element.get("Address", ""),
|
|
147
|
+
ttl=number(element.get("TTL")),
|
|
148
|
+
priority=number(element.get("MXPref")) if kind.upper() in ("MX", "MXE") else None,
|
|
149
|
+
id=number(element.get("HostId")),
|
|
150
|
+
active=element.get("IsActive", "true").lower() != "false",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def fqdn(self, domain: str) -> str:
|
|
154
|
+
return domain if self.name == "@" else f"{self.name}.{domain}"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class Hosts:
|
|
159
|
+
domain: str
|
|
160
|
+
records: list[DNSRecord] = field(default_factory=list)
|
|
161
|
+
using_our_dns: bool = True
|
|
162
|
+
email_type: Optional[str] = None
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def from_xml(cls, element: ET.Element) -> Hosts:
|
|
166
|
+
result = element.find("DomainDNSGetHostsResult")
|
|
167
|
+
node = result if result is not None else element
|
|
168
|
+
email = node.get("EmailType")
|
|
169
|
+
return cls(
|
|
170
|
+
domain=node.get("Domain", ""),
|
|
171
|
+
records=[DNSRecord.from_xml(host) for host in node.iter("host")]
|
|
172
|
+
+ [DNSRecord.from_xml(host) for host in node.iter("Host")],
|
|
173
|
+
using_our_dns=flag(node.get("IsUsingOurDNS", "true")),
|
|
174
|
+
email_type=email.upper() if email else None,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass
|
|
179
|
+
class Domain:
|
|
180
|
+
name: str
|
|
181
|
+
id: Optional[int] = None
|
|
182
|
+
user: str = ""
|
|
183
|
+
created: Optional[date] = None
|
|
184
|
+
expires: Optional[date] = None
|
|
185
|
+
is_expired: bool = False
|
|
186
|
+
is_locked: bool = False
|
|
187
|
+
auto_renew: bool = False
|
|
188
|
+
whois_guard: str = ""
|
|
189
|
+
is_premium: bool = False
|
|
190
|
+
is_our_dns: bool = False
|
|
191
|
+
|
|
192
|
+
@classmethod
|
|
193
|
+
def from_xml(cls, element: ET.Element) -> Domain:
|
|
194
|
+
return cls(
|
|
195
|
+
name=element.get("Name", ""),
|
|
196
|
+
id=number(element.get("ID")),
|
|
197
|
+
user=element.get("User", ""),
|
|
198
|
+
created=day(element.get("Created")),
|
|
199
|
+
expires=day(element.get("Expires")),
|
|
200
|
+
is_expired=flag(element.get("IsExpired")),
|
|
201
|
+
is_locked=flag(element.get("IsLocked")),
|
|
202
|
+
auto_renew=flag(element.get("AutoRenew")),
|
|
203
|
+
whois_guard=element.get("WhoisGuard", ""),
|
|
204
|
+
is_premium=flag(element.get("IsPremium")),
|
|
205
|
+
is_our_dns=flag(element.get("IsOurDNS")),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass
|
|
210
|
+
class DomainInfo:
|
|
211
|
+
name: str
|
|
212
|
+
id: Optional[int] = None
|
|
213
|
+
status: str = ""
|
|
214
|
+
owner: str = ""
|
|
215
|
+
is_owner: bool = False
|
|
216
|
+
is_premium: bool = False
|
|
217
|
+
created: Optional[date] = None
|
|
218
|
+
expires: Optional[date] = None
|
|
219
|
+
whois_guard: bool = False
|
|
220
|
+
dns_provider: str = ""
|
|
221
|
+
|
|
222
|
+
@classmethod
|
|
223
|
+
def from_xml(cls, element: ET.Element) -> DomainInfo:
|
|
224
|
+
result = element.find("DomainGetInfoResult")
|
|
225
|
+
node = result if result is not None else element
|
|
226
|
+
guard = node.find("Whoisguard")
|
|
227
|
+
dns = node.find("DnsDetails")
|
|
228
|
+
return cls(
|
|
229
|
+
name=node.get("DomainName", ""),
|
|
230
|
+
id=number(node.get("ID")),
|
|
231
|
+
status=node.get("Status", ""),
|
|
232
|
+
owner=node.get("OwnerName", ""),
|
|
233
|
+
is_owner=flag(node.get("IsOwner")),
|
|
234
|
+
is_premium=flag(node.get("IsPremium")),
|
|
235
|
+
created=day(child_text(node, "DomainDetails/CreatedDate")),
|
|
236
|
+
expires=day(child_text(node, "DomainDetails/ExpiredDate")),
|
|
237
|
+
whois_guard=flag(guard.get("Enabled")) if guard is not None else False,
|
|
238
|
+
dns_provider=dns.get("ProviderType", "") if dns is not None else "",
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass
|
|
243
|
+
class Availability:
|
|
244
|
+
domain: str
|
|
245
|
+
available: bool
|
|
246
|
+
premium: bool = False
|
|
247
|
+
registration_price: Optional[Decimal] = None
|
|
248
|
+
renewal_price: Optional[Decimal] = None
|
|
249
|
+
restore_price: Optional[Decimal] = None
|
|
250
|
+
transfer_price: Optional[Decimal] = None
|
|
251
|
+
icann_fee: Optional[Decimal] = None
|
|
252
|
+
eap_fee: Optional[Decimal] = None
|
|
253
|
+
description: str = ""
|
|
254
|
+
|
|
255
|
+
@classmethod
|
|
256
|
+
def from_xml(cls, element: ET.Element) -> Availability:
|
|
257
|
+
premium = flag(element.get("IsPremiumName"))
|
|
258
|
+
return cls(
|
|
259
|
+
domain=element.get("Domain", ""),
|
|
260
|
+
available=flag(element.get("Available")),
|
|
261
|
+
premium=premium,
|
|
262
|
+
registration_price=money(element.get("PremiumRegistrationPrice")) if premium else None,
|
|
263
|
+
renewal_price=money(element.get("PremiumRenewalPrice")) if premium else None,
|
|
264
|
+
restore_price=money(element.get("PremiumRestorePrice")) if premium else None,
|
|
265
|
+
transfer_price=money(element.get("PremiumTransferPrice")) if premium else None,
|
|
266
|
+
icann_fee=money(element.get("IcannFee")),
|
|
267
|
+
eap_fee=money(element.get("EapFee")),
|
|
268
|
+
description=element.get("Description", ""),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass
|
|
273
|
+
class Tld:
|
|
274
|
+
name: str
|
|
275
|
+
description: str = ""
|
|
276
|
+
type: str = ""
|
|
277
|
+
min_years: Optional[int] = None
|
|
278
|
+
max_years: Optional[int] = None
|
|
279
|
+
api_registerable: bool = False
|
|
280
|
+
api_renewable: bool = False
|
|
281
|
+
api_transferable: bool = False
|
|
282
|
+
supports_idn: bool = False
|
|
283
|
+
|
|
284
|
+
@classmethod
|
|
285
|
+
def from_xml(cls, element: ET.Element) -> Tld:
|
|
286
|
+
return cls(
|
|
287
|
+
name=element.get("Name", ""),
|
|
288
|
+
description=(element.text or "").strip(),
|
|
289
|
+
type=element.get("Type", ""),
|
|
290
|
+
min_years=number(element.get("MinRegisterYears")),
|
|
291
|
+
max_years=number(element.get("MaxRegisterYears")),
|
|
292
|
+
api_registerable=flag(element.get("IsApiRegisterable")),
|
|
293
|
+
api_renewable=flag(element.get("IsApiRenewable")),
|
|
294
|
+
api_transferable=flag(element.get("IsApiTransferable")),
|
|
295
|
+
supports_idn=flag(element.get("IsSupportsIDN")),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@dataclass
|
|
300
|
+
class Nameservers:
|
|
301
|
+
domain: str
|
|
302
|
+
servers: list[str] = field(default_factory=list)
|
|
303
|
+
using_our_dns: bool = True
|
|
304
|
+
|
|
305
|
+
@classmethod
|
|
306
|
+
def from_xml(cls, element: ET.Element) -> Nameservers:
|
|
307
|
+
result = element.find("DomainDNSGetListResult")
|
|
308
|
+
node = result if result is not None else element
|
|
309
|
+
return cls(
|
|
310
|
+
domain=node.get("Domain", ""),
|
|
311
|
+
servers=[(ns.text or "").strip() for ns in node.iter("Nameserver") if ns.text],
|
|
312
|
+
using_our_dns=flag(node.get("IsUsingOurDNS", "true")),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@dataclass
|
|
317
|
+
class Balances:
|
|
318
|
+
currency: str
|
|
319
|
+
available: Optional[Decimal] = None
|
|
320
|
+
account: Optional[Decimal] = None
|
|
321
|
+
earned: Optional[Decimal] = None
|
|
322
|
+
withdrawable: Optional[Decimal] = None
|
|
323
|
+
required_for_auto_renew: Optional[Decimal] = None
|
|
324
|
+
|
|
325
|
+
@classmethod
|
|
326
|
+
def from_xml(cls, element: ET.Element) -> Balances:
|
|
327
|
+
result = element.find("UserGetBalancesResult")
|
|
328
|
+
node = result if result is not None else element
|
|
329
|
+
return cls(
|
|
330
|
+
currency=node.get("Currency", ""),
|
|
331
|
+
available=money(node.get("AvailableBalance")),
|
|
332
|
+
account=money(node.get("AccountBalance")),
|
|
333
|
+
earned=money(node.get("EarnedAmount")),
|
|
334
|
+
withdrawable=money(node.get("WithdrawableAmount")),
|
|
335
|
+
required_for_auto_renew=money(node.get("FundsRequiredForAutoRenew")),
|
|
336
|
+
)
|
namecheap_sdk/py.typed
ADDED
|
File without changes
|