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
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import xml.etree.ElementTree as ET
|
|
5
|
+
from typing import Any, Callable, Iterable, Optional, Sequence, Union
|
|
6
|
+
|
|
7
|
+
from .._base import Call, Flow
|
|
8
|
+
from ..errors import APIError
|
|
9
|
+
from ..messages import text
|
|
10
|
+
from ..models import HOST_TYPES, DNSRecord, Hosts, RecordType, RecordTypeLike, hostname, record_type
|
|
11
|
+
|
|
12
|
+
RecordValues = Union[str, DNSRecord, Iterable[Union[str, DNSRecord]]]
|
|
13
|
+
Change = Callable[[list[DNSRecord]], tuple[list[DNSRecord], Any]]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def domain_name(value: str) -> str:
|
|
17
|
+
name = value.strip().lower().rstrip(".")
|
|
18
|
+
if not name:
|
|
19
|
+
raise ValueError(text("value.empty_domain"))
|
|
20
|
+
return name
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def split_domain(value: str) -> tuple[str, str]:
|
|
24
|
+
name = domain_name(value)
|
|
25
|
+
if "." not in name:
|
|
26
|
+
raise ValueError(text("value.no_tld", domain=value))
|
|
27
|
+
sld, tld = name.split(".", 1)
|
|
28
|
+
return sld, tld
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def relative_name(name: str, domain: str) -> str:
|
|
32
|
+
value = name.strip().lower()
|
|
33
|
+
if not value:
|
|
34
|
+
raise ValueError(text("value.empty_name"))
|
|
35
|
+
if value in ("@", domain, f"{domain}."):
|
|
36
|
+
return "@"
|
|
37
|
+
bare = value.rstrip(".")
|
|
38
|
+
if bare.endswith(f".{domain}"):
|
|
39
|
+
return bare[: -len(domain) - 1]
|
|
40
|
+
if value.endswith("."):
|
|
41
|
+
raise ValueError(text("value.foreign_name", name=name, domain=domain))
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def ip_record_type(ip: str) -> RecordType:
|
|
46
|
+
return RecordType.A if ipaddress.ip_address(ip).version == 4 else RecordType.AAAA
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def email_type(records: Sequence[DNSRecord], current: Optional[str], explicit: Optional[str]) -> Optional[str]:
|
|
50
|
+
if explicit:
|
|
51
|
+
return explicit.strip().upper()
|
|
52
|
+
kinds = {record.type for record in records}
|
|
53
|
+
if "MXE" in kinds:
|
|
54
|
+
return "MXE"
|
|
55
|
+
if "MX" in kinds:
|
|
56
|
+
return "MX"
|
|
57
|
+
if current and current not in ("MX", "MXE", "NONE"):
|
|
58
|
+
return current
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _same(kind: str, left: str, right: str) -> bool:
|
|
63
|
+
if kind in HOST_TYPES:
|
|
64
|
+
return left.rstrip(".").lower() == right.rstrip(".").lower()
|
|
65
|
+
return left == right
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class DNS:
|
|
69
|
+
def __init__(self, client: Any) -> None:
|
|
70
|
+
self._client = client
|
|
71
|
+
|
|
72
|
+
def _command(self, name: str) -> str:
|
|
73
|
+
return self._client.settings.command(name)
|
|
74
|
+
|
|
75
|
+
def _hosts_call(self, domain: str) -> Call:
|
|
76
|
+
sld, tld = split_domain(domain)
|
|
77
|
+
return Call(self._command("get_hosts"), {"SLD": sld, "TLD": tld}, parse=Hosts.from_xml)
|
|
78
|
+
|
|
79
|
+
def _ttl(self, value: Optional[int]) -> int:
|
|
80
|
+
ttl = self._client.settings.default_ttl if value is None else int(value)
|
|
81
|
+
if not 60 <= ttl <= 60000:
|
|
82
|
+
raise ValueError(text("value.ttl_range", ttl=ttl))
|
|
83
|
+
return ttl
|
|
84
|
+
|
|
85
|
+
def _prepare(self, zone: str, records: Iterable[DNSRecord]) -> list[DNSRecord]:
|
|
86
|
+
prepared = []
|
|
87
|
+
for record in records:
|
|
88
|
+
copy = DNSRecord(record.type, relative_name(record.name, zone), record.data, record.ttl, record.priority)
|
|
89
|
+
if copy.type in HOST_TYPES:
|
|
90
|
+
copy.data = hostname(copy.data)
|
|
91
|
+
if copy.ttl is not None:
|
|
92
|
+
self._ttl(copy.ttl)
|
|
93
|
+
prepared.append(copy)
|
|
94
|
+
return prepared
|
|
95
|
+
|
|
96
|
+
def _set_call(self, domain: str, records: Sequence[DNSRecord], mail: Optional[str]) -> Call:
|
|
97
|
+
sld, tld = split_domain(domain)
|
|
98
|
+
params: dict[str, Any] = {"SLD": sld, "TLD": tld}
|
|
99
|
+
for index, record in enumerate(records, start=1):
|
|
100
|
+
params[f"HostName{index}"] = record.name
|
|
101
|
+
params[f"RecordType{index}"] = record.type
|
|
102
|
+
params[f"Address{index}"] = record.data
|
|
103
|
+
params[f"TTL{index}"] = self._ttl(record.ttl)
|
|
104
|
+
if record.type in ("MX", "MXE"):
|
|
105
|
+
params[f"MXPref{index}"] = record.priority if record.priority is not None else 10
|
|
106
|
+
if mail:
|
|
107
|
+
params["EmailType"] = mail
|
|
108
|
+
|
|
109
|
+
def parse(element: ET.Element) -> None:
|
|
110
|
+
result = element.find("DomainDNSSetHostsResult")
|
|
111
|
+
if result is not None and result.get("IsSuccess", "true").lower() == "false":
|
|
112
|
+
raise APIError(text("error.not_saved", domain=domain), command=self._command("set_hosts"))
|
|
113
|
+
|
|
114
|
+
return Call(self._command("set_hosts"), params, parse=parse, write=True)
|
|
115
|
+
|
|
116
|
+
def _rewrite(self, domain: str, change: Change, mail: Optional[str] = None) -> Flow[Any]:
|
|
117
|
+
current: Hosts = yield self._hosts_call(domain)
|
|
118
|
+
before = list(current.records)
|
|
119
|
+
records, result = change(list(before))
|
|
120
|
+
wanted_mail = email_type(records, current.email_type, mail)
|
|
121
|
+
if records == before and (mail is None or wanted_mail == current.email_type):
|
|
122
|
+
return result
|
|
123
|
+
yield self._set_call(domain, records, wanted_mail)
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
def hosts(self, domain: str) -> Any:
|
|
127
|
+
return self._client._execute(self._hosts_call(domain))
|
|
128
|
+
|
|
129
|
+
def list(self, domain: str, type: Optional[RecordTypeLike] = None, name: Optional[str] = None) -> Any:
|
|
130
|
+
zone = domain_name(domain)
|
|
131
|
+
kind = record_type(type) if type is not None else None
|
|
132
|
+
label = relative_name(name, zone) if name else None
|
|
133
|
+
call = self._hosts_call(zone)
|
|
134
|
+
call.parse = lambda element: [r for r in Hosts.from_xml(element).records
|
|
135
|
+
if (kind is None or r.type == kind) and (label is None or r.name == label)]
|
|
136
|
+
return self._client._execute(call)
|
|
137
|
+
|
|
138
|
+
def exists(self, domain: str, type: RecordTypeLike, name: str) -> Any:
|
|
139
|
+
zone, kind = domain_name(domain), record_type(type)
|
|
140
|
+
label = relative_name(name, zone)
|
|
141
|
+
call = self._hosts_call(zone)
|
|
142
|
+
call.parse = lambda element: any(r.type == kind and r.name == label for r in Hosts.from_xml(element).records)
|
|
143
|
+
return self._client._execute(call)
|
|
144
|
+
|
|
145
|
+
def add(self, domain: str, *records: DNSRecord, email_type: Optional[str] = None) -> Any:
|
|
146
|
+
if not records:
|
|
147
|
+
raise ValueError(text("value.add_needs_records"))
|
|
148
|
+
zone = domain_name(domain)
|
|
149
|
+
new = self._prepare(zone, records)
|
|
150
|
+
|
|
151
|
+
def change(current: list[DNSRecord]) -> tuple[list[DNSRecord], list[DNSRecord]]:
|
|
152
|
+
added = [r for r in new if not any(o.type == r.type and o.name == r.name and _same(r.type, o.data, r.data)
|
|
153
|
+
for o in current)]
|
|
154
|
+
return current + added, added
|
|
155
|
+
|
|
156
|
+
return self._client._drive(self._rewrite(zone, change, email_type))
|
|
157
|
+
|
|
158
|
+
def replace(
|
|
159
|
+
self, domain: str, type: RecordTypeLike, name: str, values: RecordValues, *, ttl: Optional[int] = None
|
|
160
|
+
) -> Any:
|
|
161
|
+
zone, kind = domain_name(domain), record_type(type)
|
|
162
|
+
label = relative_name(name, zone)
|
|
163
|
+
if isinstance(values, (str, DNSRecord)):
|
|
164
|
+
values = [values]
|
|
165
|
+
wanted = [v if isinstance(v, DNSRecord) else DNSRecord(kind, label, str(v), ttl) for v in values]
|
|
166
|
+
if not wanted:
|
|
167
|
+
raise ValueError(text("value.no_values"))
|
|
168
|
+
for record in wanted:
|
|
169
|
+
record.type, record.name = kind, label
|
|
170
|
+
if record.ttl is None:
|
|
171
|
+
record.ttl = ttl
|
|
172
|
+
wanted = self._prepare(zone, wanted)
|
|
173
|
+
|
|
174
|
+
def change(current: list[DNSRecord]) -> tuple[list[DNSRecord], list[DNSRecord]]:
|
|
175
|
+
old = [r for r in current if r.type == kind and r.name == label]
|
|
176
|
+
for record in wanted:
|
|
177
|
+
match = next((o for o in old if _same(kind, o.data, record.data)), None)
|
|
178
|
+
if record.ttl is None and match is not None:
|
|
179
|
+
record.ttl = match.ttl
|
|
180
|
+
if record.priority is None and match is not None:
|
|
181
|
+
record.priority = match.priority
|
|
182
|
+
position = next((i for i, r in enumerate(current) if r.type == kind and r.name == label), len(current))
|
|
183
|
+
kept = [r for r in current if not (r.type == kind and r.name == label)]
|
|
184
|
+
index = min(position, len(kept))
|
|
185
|
+
return kept[:index] + wanted + kept[index:], wanted
|
|
186
|
+
|
|
187
|
+
return self._client._drive(self._rewrite(zone, change))
|
|
188
|
+
|
|
189
|
+
def replace_all(self, domain: str, records: Sequence[DNSRecord], *, email_type: Optional[str] = None) -> Any:
|
|
190
|
+
zone = domain_name(domain)
|
|
191
|
+
new = self._prepare(zone, records)
|
|
192
|
+
return self._client._drive(self._rewrite(zone, lambda current: (new, None), email_type))
|
|
193
|
+
|
|
194
|
+
def delete(self, domain: str, type: RecordTypeLike, name: str, data: Optional[str] = None) -> Any:
|
|
195
|
+
zone, kind = domain_name(domain), record_type(type)
|
|
196
|
+
label = relative_name(name, zone)
|
|
197
|
+
|
|
198
|
+
def gone(record: DNSRecord) -> bool:
|
|
199
|
+
return record.type == kind and record.name == label and (data is None or _same(kind, record.data, data))
|
|
200
|
+
|
|
201
|
+
def change(current: list[DNSRecord]) -> tuple[list[DNSRecord], int]:
|
|
202
|
+
kept = [r for r in current if not gone(r)]
|
|
203
|
+
return kept, len(current) - len(kept)
|
|
204
|
+
|
|
205
|
+
return self._client._drive(self._rewrite(zone, change))
|
|
206
|
+
|
|
207
|
+
def point(self, domain: str, name: str, ip: str, *, ttl: Optional[int] = None) -> Any:
|
|
208
|
+
address = str(ipaddress.ip_address(ip))
|
|
209
|
+
return self.replace(domain, ip_record_type(address), name, [address], ttl=ttl)
|
|
210
|
+
|
|
211
|
+
def unpoint(self, domain: str, name: str, ip_version: int = 4) -> Any:
|
|
212
|
+
return self.delete(domain, RecordType.A if ip_version == 4 else RecordType.AAAA, name)
|
|
213
|
+
|
|
214
|
+
def alias(self, domain: str, name: str, target: str, *, ttl: Optional[int] = None) -> Any:
|
|
215
|
+
return self.replace(domain, RecordType.CNAME, name, [target], ttl=ttl)
|
|
216
|
+
|
|
217
|
+
def set_txt(self, domain: str, name: str, values: Union[str, Iterable[str]], *, ttl: Optional[int] = None) -> Any:
|
|
218
|
+
return self.replace(domain, RecordType.TXT, name, values, ttl=ttl)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class AsyncDNS(DNS):
|
|
222
|
+
pass
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import xml.etree.ElementTree as ET
|
|
4
|
+
from typing import Any, AsyncIterator, Iterable, Iterator, Optional
|
|
5
|
+
|
|
6
|
+
from .._base import Call, Flow
|
|
7
|
+
from ..messages import text
|
|
8
|
+
from ..models import Availability, Domain, DomainInfo, Nameservers, Tld, flag, number
|
|
9
|
+
from .dns import domain_name, split_domain
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _page(element: ET.Element) -> tuple[list[Domain], int]:
|
|
13
|
+
rows = [Domain.from_xml(row) for row in element.iter("Domain")]
|
|
14
|
+
total = number(element.findtext("Paging/TotalItems")) or 0
|
|
15
|
+
return rows, total
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _updated(tag: str, attribute: str) -> Any:
|
|
19
|
+
def parse(element: ET.Element) -> bool:
|
|
20
|
+
node = element.find(tag)
|
|
21
|
+
return flag(node.get(attribute)) if node is not None else True
|
|
22
|
+
|
|
23
|
+
return parse
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _DomainsBase:
|
|
27
|
+
def __init__(self, client: Any) -> None:
|
|
28
|
+
self._client = client
|
|
29
|
+
|
|
30
|
+
def _command(self, name: str) -> str:
|
|
31
|
+
return self._client.settings.command(name)
|
|
32
|
+
|
|
33
|
+
def _run(self, call: Call) -> Any:
|
|
34
|
+
return self._client._execute(call)
|
|
35
|
+
|
|
36
|
+
def _page_call(self, page: int, list_type: str, search: Optional[str], sort_by: Optional[str]) -> Call:
|
|
37
|
+
params = {"ListType": list_type.upper(), "SearchTerm": search, "SortBy": sort_by.upper() if sort_by else None,
|
|
38
|
+
"Page": page, "PageSize": self._client.settings.page_size}
|
|
39
|
+
return Call(self._command("list_domains"), params, parse=_page)
|
|
40
|
+
|
|
41
|
+
def list(self, *, list_type: str = "ALL", search: Optional[str] = None, sort_by: Optional[str] = None) -> Any:
|
|
42
|
+
size = self._client.settings.page_size
|
|
43
|
+
|
|
44
|
+
def flow() -> Flow[list[Domain]]:
|
|
45
|
+
found: list[Domain] = []
|
|
46
|
+
page = 1
|
|
47
|
+
while True:
|
|
48
|
+
rows, total = yield self._page_call(page, list_type, search, sort_by)
|
|
49
|
+
found.extend(rows)
|
|
50
|
+
if not rows or page * size >= total:
|
|
51
|
+
return found
|
|
52
|
+
page += 1
|
|
53
|
+
|
|
54
|
+
return self._client._drive(flow())
|
|
55
|
+
|
|
56
|
+
def get(self, domain: str) -> Any:
|
|
57
|
+
return self._run(Call(self._command("domain_info"), {"DomainName": domain_name(domain)},
|
|
58
|
+
parse=DomainInfo.from_xml))
|
|
59
|
+
|
|
60
|
+
def available(self, domain: str) -> Any:
|
|
61
|
+
call = Call(self._command("check"), {"DomainList": domain_name(domain)},
|
|
62
|
+
parse=lambda element: Availability.from_xml(next(element.iter("DomainCheckResult"))))
|
|
63
|
+
return self._run(call)
|
|
64
|
+
|
|
65
|
+
def available_many(self, domains: Iterable[str]) -> Any:
|
|
66
|
+
names = [domain_name(d) for d in domains]
|
|
67
|
+
if not names:
|
|
68
|
+
raise ValueError(text("value.no_domains"))
|
|
69
|
+
parse = lambda element: [Availability.from_xml(row) for row in element.iter("DomainCheckResult")]
|
|
70
|
+
return self._run(Call(self._command("check"), {"DomainList": names}, parse=parse))
|
|
71
|
+
|
|
72
|
+
def tlds(self) -> Any:
|
|
73
|
+
parse = lambda element: [Tld.from_xml(row) for row in element.iter("Tld")]
|
|
74
|
+
return self._run(Call(self._command("tlds"), parse=parse))
|
|
75
|
+
|
|
76
|
+
def nameservers(self, domain: str) -> Any:
|
|
77
|
+
sld, tld = split_domain(domain)
|
|
78
|
+
return self._run(Call(self._command("get_nameservers"), {"SLD": sld, "TLD": tld}, parse=Nameservers.from_xml))
|
|
79
|
+
|
|
80
|
+
def set_nameservers(self, domain: str, servers: Iterable[str]) -> Any:
|
|
81
|
+
names = [server.strip().rstrip(".") for server in servers if server.strip()]
|
|
82
|
+
if len(names) < 2:
|
|
83
|
+
raise ValueError(text("value.nameserver_count", count=len(names)))
|
|
84
|
+
sld, tld = split_domain(domain)
|
|
85
|
+
call = Call(self._command("set_custom_nameservers"), {"SLD": sld, "TLD": tld, "NameServers": names},
|
|
86
|
+
parse=_updated("DomainDNSSetCustomResult", "Updated"))
|
|
87
|
+
return self._run(call)
|
|
88
|
+
|
|
89
|
+
def use_default_nameservers(self, domain: str) -> Any:
|
|
90
|
+
sld, tld = split_domain(domain)
|
|
91
|
+
call = Call(self._command("set_default_nameservers"), {"SLD": sld, "TLD": tld},
|
|
92
|
+
parse=_updated("DomainDNSSetDefaultResult", "Updated"))
|
|
93
|
+
return self._run(call)
|
|
94
|
+
|
|
95
|
+
def is_locked(self, domain: str) -> Any:
|
|
96
|
+
call = Call(self._command("get_lock"), {"DomainName": domain_name(domain)},
|
|
97
|
+
parse=_updated("DomainGetRegistrarLockResult", "RegistrarLockStatus"))
|
|
98
|
+
return self._run(call)
|
|
99
|
+
|
|
100
|
+
def lock(self, domain: str) -> Any:
|
|
101
|
+
return self._set_lock(domain, "LOCK")
|
|
102
|
+
|
|
103
|
+
def unlock(self, domain: str) -> Any:
|
|
104
|
+
return self._set_lock(domain, "UNLOCK")
|
|
105
|
+
|
|
106
|
+
def _set_lock(self, domain: str, action: str) -> Any:
|
|
107
|
+
call = Call(self._command("set_lock"), {"DomainName": domain_name(domain), "LockAction": action},
|
|
108
|
+
parse=_updated("DomainSetRegistrarLockResult", "IsSuccess"))
|
|
109
|
+
return self._run(call)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Domains(_DomainsBase):
|
|
113
|
+
def iter(self, *, list_type: str = "ALL", search: Optional[str] = None,
|
|
114
|
+
sort_by: Optional[str] = None) -> Iterator[Domain]:
|
|
115
|
+
size = self._client.settings.page_size
|
|
116
|
+
page = 1
|
|
117
|
+
while True:
|
|
118
|
+
rows, total = self._client._execute(self._page_call(page, list_type, search, sort_by))
|
|
119
|
+
yield from rows
|
|
120
|
+
if not rows or page * size >= total:
|
|
121
|
+
return
|
|
122
|
+
page += 1
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class AsyncDomains(_DomainsBase):
|
|
126
|
+
async def iter(self, *, list_type: str = "ALL", search: Optional[str] = None,
|
|
127
|
+
sort_by: Optional[str] = None) -> AsyncIterator[Domain]:
|
|
128
|
+
size = self._client.settings.page_size
|
|
129
|
+
page = 1
|
|
130
|
+
while True:
|
|
131
|
+
rows, total = await self._client._execute(self._page_call(page, list_type, search, sort_by))
|
|
132
|
+
for row in rows:
|
|
133
|
+
yield row
|
|
134
|
+
if not rows or page * size >= total:
|
|
135
|
+
return
|
|
136
|
+
page += 1
|