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