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,354 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: namecheap-python-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A typed Python client for the Namecheap Domains and DNS API, sync and async.
|
|
5
|
+
Project-URL: Homepage, https://theqiyas.cc
|
|
6
|
+
Project-URL: Source, https://github.com/qiyascc/namecheap-python-sdk
|
|
7
|
+
Project-URL: Issues, https://github.com/qiyascc/namecheap-python-sdk/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/qiyascc/namecheap-python-sdk/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: qiyascc <qiyasturk@gmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api,dns,domains,namecheap,sdk,subdomain
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Internet :: Name Service (DNS)
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: goodconf>=7.1
|
|
25
|
+
Requires-Dist: httpx<1,>=0.24
|
|
26
|
+
Requires-Dist: pydantic>=2.7
|
|
27
|
+
Requires-Dist: pyyaml>=6
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: goodconf[toml]>=7.1; extra == 'dev'
|
|
30
|
+
Requires-Dist: goodconf[yaml]>=7.1; extra == 'dev'
|
|
31
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
34
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
35
|
+
Requires-Dist: types-pyyaml; extra == 'dev'
|
|
36
|
+
Provides-Extra: toml
|
|
37
|
+
Requires-Dist: goodconf[toml]>=7.1; extra == 'toml'
|
|
38
|
+
Provides-Extra: yaml
|
|
39
|
+
Requires-Dist: goodconf[yaml]>=7.1; extra == 'yaml'
|
|
40
|
+
Description-Content-Type: text/markdown
|
|
41
|
+
|
|
42
|
+
# namecheap-python-sdk
|
|
43
|
+
|
|
44
|
+
A small, typed Python client for the Namecheap Domains and DNS API. It works with both sync and async code, turns Namecheap's XML into plain dataclasses, and makes the "replace everything" DNS API safe to use one record at a time.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from namecheap_sdk import Namecheap
|
|
48
|
+
|
|
49
|
+
with Namecheap("api_user", "API_KEY", client_ip="203.0.113.5") as nc:
|
|
50
|
+
nc.dns.point("example.com", "shop", "203.0.113.10")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
That call makes `shop.example.com` resolve to `203.0.113.10` and leaves every other record of the domain as it was.
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install namecheap-python-sdk
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Requires Python 3.10 or newer. Dependencies: [httpx](https://www.python-httpx.org/), [goodconf](https://github.com/lincolnloop/goodconf), pydantic and PyYAML. XML is parsed with the standard library.
|
|
62
|
+
|
|
63
|
+
The import name is `namecheap_sdk`, so it does not clash with older `namecheap` packages.
|
|
64
|
+
|
|
65
|
+
## Before you start
|
|
66
|
+
|
|
67
|
+
1. Enable API access under Profile, Tools, Business & Dev Tools, Namecheap API Access. Namecheap only opens the production API to accounts that meet its requirements, so check that page if every call fails.
|
|
68
|
+
2. Whitelist the IPv4 address of the machine that makes the calls on the same page. Every request carries that address as `ClientIp`, and calls from any other address are rejected with `AuthenticationError`.
|
|
69
|
+
3. Try things in the sandbox first. It is a separate site, [sandbox.namecheap.com](https://www.sandbox.namecheap.com), with its own account, key and whitelist.
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
Settings are loaded with [goodconf](https://github.com/lincolnloop/goodconf), so you can configure the client from code, from environment variables or from a config file. Values passed in code win over the environment, and the environment wins over the file.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
nc = Namecheap("api_user", "API_KEY", client_ip="203.0.113.5")
|
|
77
|
+
nc = Namecheap("api_user", "API_KEY", client_ip="203.0.113.5", environment="sandbox")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
export NAMECHEAP_API_USER=api_user
|
|
82
|
+
export NAMECHEAP_API_KEY=...
|
|
83
|
+
export NAMECHEAP_CLIENT_IP=203.0.113.5
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```toml
|
|
87
|
+
api_user = "api_user"
|
|
88
|
+
api_key = "..."
|
|
89
|
+
client_ip = "203.0.113.5"
|
|
90
|
+
environment = "production"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
With no arguments, the client looks for `namecheap.toml`, `namecheap.yaml`, `namecheap.yml` or `namecheap.json` in the working directory. To load a file from somewhere else, set `NAMECHEAP_CONFIG=/path/to/file`, or pass `config_file=` to the client. YAML files need the `yaml` extra, and TOML files on Python 3.10 need the `toml` extra:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
pip install "namecheap-python-sdk[yaml]"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
| Setting | Env variable | Default |
|
|
100
|
+
| --- | --- | --- |
|
|
101
|
+
| `api_user` | `NAMECHEAP_API_USER` | |
|
|
102
|
+
| `api_key` | `NAMECHEAP_API_KEY` | |
|
|
103
|
+
| `username` | `NAMECHEAP_USERNAME` | same as `api_user` |
|
|
104
|
+
| `client_ip` | `NAMECHEAP_CLIENT_IP` | |
|
|
105
|
+
| `environment` | `NAMECHEAP_ENVIRONMENT` | `production` |
|
|
106
|
+
| `base_url` | `NAMECHEAP_BASE_URL` | taken from `environment` |
|
|
107
|
+
| `production_url` | `NAMECHEAP_PRODUCTION_URL` | `https://api.namecheap.com/xml.response` |
|
|
108
|
+
| `sandbox_url` | `NAMECHEAP_SANDBOX_URL` | `https://api.sandbox.namecheap.com/xml.response` |
|
|
109
|
+
| `commands.*` | `NAMECHEAP_COMMANDS__<NAME>` | see below |
|
|
110
|
+
| `timeout` | `NAMECHEAP_TIMEOUT` | `30` |
|
|
111
|
+
| `max_retries` | `NAMECHEAP_MAX_RETRIES` | `3` |
|
|
112
|
+
| `max_retry_wait` | `NAMECHEAP_MAX_RETRY_WAIT` | `60` |
|
|
113
|
+
| `backoff_base` | `NAMECHEAP_BACKOFF_BASE` | `1` |
|
|
114
|
+
| `backoff_cap` | `NAMECHEAP_BACKOFF_CAP` | `16` |
|
|
115
|
+
| `page_size` | `NAMECHEAP_PAGE_SIZE` | `100` (the API allows 10 to 100) |
|
|
116
|
+
| `default_ttl` | `NAMECHEAP_DEFAULT_TTL` | `1800` |
|
|
117
|
+
| `user_agent` | `NAMECHEAP_USER_AGENT` | `namecheap-python-sdk/{version}` |
|
|
118
|
+
|
|
119
|
+
`username` is the account the commands run on. It only differs from `api_user` when a reseller acts for a customer.
|
|
120
|
+
|
|
121
|
+
### Commands
|
|
122
|
+
|
|
123
|
+
Namecheap has one URL and picks the action from a `Command` parameter. The command names are part of the settings, so nothing is pinned in the code:
|
|
124
|
+
|
|
125
|
+
| Name | Default |
|
|
126
|
+
| --- | --- |
|
|
127
|
+
| `get_hosts` | `namecheap.domains.dns.getHosts` |
|
|
128
|
+
| `set_hosts` | `namecheap.domains.dns.setHosts` |
|
|
129
|
+
| `get_nameservers` | `namecheap.domains.dns.getList` |
|
|
130
|
+
| `set_custom_nameservers` | `namecheap.domains.dns.setCustom` |
|
|
131
|
+
| `set_default_nameservers` | `namecheap.domains.dns.setDefault` |
|
|
132
|
+
| `list_domains` | `namecheap.domains.getList` |
|
|
133
|
+
| `domain_info` | `namecheap.domains.getInfo` |
|
|
134
|
+
| `check` | `namecheap.domains.check` |
|
|
135
|
+
| `tlds` | `namecheap.domains.getTldList` |
|
|
136
|
+
| `get_lock` | `namecheap.domains.getRegistrarLock` |
|
|
137
|
+
| `set_lock` | `namecheap.domains.setRegistrarLock` |
|
|
138
|
+
| `balances` | `namecheap.users.getBalances` |
|
|
139
|
+
|
|
140
|
+
## DNS
|
|
141
|
+
|
|
142
|
+
These methods work while the domain uses Namecheap's own name servers (BasicDNS or PremiumDNS). Record names are relative to the domain and the apex is `@`. You can also pass a full name like `www.example.com`, it is turned into `www` for you.
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from namecheap_sdk import Namecheap, DNSRecord
|
|
146
|
+
|
|
147
|
+
nc = Namecheap()
|
|
148
|
+
|
|
149
|
+
nc.dns.list("example.com")
|
|
150
|
+
nc.dns.list("example.com", "A")
|
|
151
|
+
nc.dns.list("example.com", "A", "www")
|
|
152
|
+
nc.dns.exists("example.com", "A", "shop")
|
|
153
|
+
|
|
154
|
+
hosts = nc.dns.hosts("example.com")
|
|
155
|
+
hosts.records, hosts.email_type, hosts.using_our_dns
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Each record is a `DNSRecord`:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
DNSRecord(type='A', name='www', data='203.0.113.10', ttl=1800, priority=None, id=12, active=True)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### How changes are saved
|
|
165
|
+
|
|
166
|
+
Namecheap's `setHosts` replaces the whole zone: any record left out of the call is deleted. So every change here reads the current records first, applies your change, and writes the full list back. If nothing actually changes, nothing is written. The mail setting (`EmailType`) is kept as it was, set to `MX` when the zone has MX records, or set to whatever you pass as `email_type=`.
|
|
167
|
+
|
|
168
|
+
Two programs editing the same domain at the same moment can still overwrite each other, because the API has no locking. Keep DNS changes for one domain in one place.
|
|
169
|
+
|
|
170
|
+
### Creating and changing records
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
nc.dns.add(
|
|
174
|
+
"example.com",
|
|
175
|
+
DNSRecord.a("api", "203.0.113.20"),
|
|
176
|
+
DNSRecord.txt("@", "google-site-verification=abc"),
|
|
177
|
+
DNSRecord.mx("@", "mail.example.com", priority=10),
|
|
178
|
+
DNSRecord.caa("@", "letsencrypt.org"),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
nc.dns.replace("example.com", "A", "www", ["203.0.113.10", "203.0.113.11"], ttl=600)
|
|
182
|
+
|
|
183
|
+
nc.dns.delete("example.com", "A", "old")
|
|
184
|
+
nc.dns.delete("example.com", "A", "www", data="203.0.113.11")
|
|
185
|
+
|
|
186
|
+
nc.dns.replace_all("example.com", [DNSRecord.a("@", "203.0.113.10"), DNSRecord.cname("www", "example.com")])
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`add` keeps everything and adds records that are not there yet. `replace` swaps every record with that type and name and keeps them in the same place in the zone. `delete` returns how many records it removed. `replace_all` writes exactly the list you pass.
|
|
190
|
+
|
|
191
|
+
Namecheap also has its own redirect records, which other providers do not:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
nc.dns.add("example.com", DNSRecord.url("old", "https://example.com/new"))
|
|
195
|
+
nc.dns.add("example.com", DNSRecord.url("tmp", "https://example.com", permanent=False))
|
|
196
|
+
nc.dns.add("example.com", DNSRecord.frame("promo", "https://landing.example.net"))
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
TTL must be between 60 and 60000 seconds. Host names in `CNAME`, `ALIAS`, `NS` and `MX` records get their trailing dot, and `DNSRecord.caa("@", "letsencrypt.org")` becomes `0 issue "letsencrypt.org"`.
|
|
200
|
+
|
|
201
|
+
### Shortcuts
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
nc.dns.point("example.com", "shop", "203.0.113.10")
|
|
205
|
+
nc.dns.point("example.com", "shop", "2001:db8::10")
|
|
206
|
+
nc.dns.alias("example.com", "docs", "example.github.io")
|
|
207
|
+
nc.dns.set_txt("example.com", "_acme-challenge", "token-value", ttl=60)
|
|
208
|
+
nc.dns.unpoint("example.com", "shop")
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`point` picks `A` or `AAAA` from the address you pass.
|
|
212
|
+
|
|
213
|
+
### Example: one subdomain per customer
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
from namecheap_sdk import Namecheap
|
|
217
|
+
|
|
218
|
+
SERVER_IP = "203.0.113.10"
|
|
219
|
+
|
|
220
|
+
def create_customer_site(slug: str) -> str:
|
|
221
|
+
with Namecheap() as nc:
|
|
222
|
+
if nc.dns.exists("example.com", "A", slug):
|
|
223
|
+
raise ValueError(f"{slug}.example.com is already taken")
|
|
224
|
+
nc.dns.point("example.com", slug, SERVER_IP, ttl=300)
|
|
225
|
+
return f"{slug}.example.com"
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Domains
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
for domain in nc.domains.iter():
|
|
232
|
+
print(domain.name, domain.expires, domain.auto_renew)
|
|
233
|
+
|
|
234
|
+
nc.domains.list(list_type="EXPIRING", sort_by="EXPIREDATE")
|
|
235
|
+
nc.domains.list(search="shop")
|
|
236
|
+
|
|
237
|
+
info = nc.domains.get("example.com")
|
|
238
|
+
info.status, info.expires, info.whois_guard
|
|
239
|
+
|
|
240
|
+
check = nc.domains.available("my-new-idea.com")
|
|
241
|
+
if check.available and check.premium:
|
|
242
|
+
print(check.registration_price)
|
|
243
|
+
|
|
244
|
+
nc.domains.available_many(["one.com", "two.net"])
|
|
245
|
+
nc.domains.tlds()
|
|
246
|
+
|
|
247
|
+
nc.domains.nameservers("example.com").servers
|
|
248
|
+
nc.domains.set_nameservers("example.com", ["ns1.example.net", "ns2.example.net"])
|
|
249
|
+
nc.domains.use_default_nameservers("example.com")
|
|
250
|
+
|
|
251
|
+
nc.domains.is_locked("example.com")
|
|
252
|
+
nc.domains.lock("example.com")
|
|
253
|
+
nc.domains.unlock("example.com")
|
|
254
|
+
|
|
255
|
+
nc.balances()
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
`use_default_nameservers` switches the domain back to Namecheap BasicDNS, which the DNS methods need.
|
|
259
|
+
|
|
260
|
+
## Async
|
|
261
|
+
|
|
262
|
+
`AsyncNamecheap` has the same methods. Await them.
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
import asyncio
|
|
266
|
+
from namecheap_sdk import AsyncNamecheap
|
|
267
|
+
|
|
268
|
+
async def main():
|
|
269
|
+
async with AsyncNamecheap() as nc:
|
|
270
|
+
records = await nc.dns.list("example.com", "A")
|
|
271
|
+
await nc.dns.point("example.com", "shop", "203.0.113.10")
|
|
272
|
+
async for domain in nc.domains.iter():
|
|
273
|
+
print(domain.name)
|
|
274
|
+
|
|
275
|
+
asyncio.run(main())
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## Errors
|
|
279
|
+
|
|
280
|
+
Namecheap answers HTTP 200 even when a call fails and puts the error in the XML. The client turns those answers into exceptions. All of them inherit from `NamecheapError`.
|
|
281
|
+
|
|
282
|
+
| Exception | When |
|
|
283
|
+
| --- | --- |
|
|
284
|
+
| `AuthenticationError` | wrong user or key, missing credentials, or a `ClientIp` that is not whitelisted |
|
|
285
|
+
| `NotFoundError` | the domain is not found or not in this account (2019166, 2016166) |
|
|
286
|
+
| `PermissionDeniedError` | the domain cannot be edited through the API (2030166) |
|
|
287
|
+
| `ValidationError` | a parameter was rejected, for example too many records |
|
|
288
|
+
| `RateLimitError` | Namecheap says there are too many requests |
|
|
289
|
+
| `ServerError` | 5xx answers and errors from Namecheap's upstream registry |
|
|
290
|
+
| `APIError` | any other error in the XML |
|
|
291
|
+
| `APIConnectionError` / `APITimeoutError` | the request never got an answer |
|
|
292
|
+
|
|
293
|
+
Errors carry Namecheap's error `number`, the full `errors` list, the `command` that failed and the raw `response`.
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
from namecheap_sdk import NotFoundError
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
nc.dns.list("not-mine.com")
|
|
300
|
+
except NotFoundError as exc:
|
|
301
|
+
print(exc.number, exc.message)
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## Retries and timeouts
|
|
305
|
+
|
|
306
|
+
Namecheap does not publish its limits in the API docs, and its support has quoted around 20 calls per minute, 700 per hour and 8,000 per day for a key. Too many calls come back as an error, which the client waits on and retries with exponential backoff, together with server errors and dropped connections. Every command this SDK sends can be repeated safely, because `setHosts` writes the full list each time.
|
|
307
|
+
|
|
308
|
+
```python
|
|
309
|
+
nc = Namecheap(timeout=10, max_retries=5)
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Set `max_retries=0` to turn retries off. To use proxies or a custom transport, pass your own `httpx.Client` or `httpx.AsyncClient` as `http_client=`.
|
|
313
|
+
|
|
314
|
+
## Messages
|
|
315
|
+
|
|
316
|
+
Every error and validation message the SDK produces is stored in `namecheap_sdk/messages.yaml`, so none of them are hardcoded. You can replace some or all of them, for example to translate them:
|
|
317
|
+
|
|
318
|
+
```yaml
|
|
319
|
+
value:
|
|
320
|
+
empty_name: Record adı boş ola bilməz
|
|
321
|
+
error:
|
|
322
|
+
timeout: "Sorğu vaxtı bitdi: {error}"
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
```python
|
|
326
|
+
from namecheap_sdk import messages
|
|
327
|
+
|
|
328
|
+
messages.use("messages.az.yaml")
|
|
329
|
+
messages.use({"value.empty_name": "Record adı boş ola bilməz"})
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Unknown keys raise `KeyError`, which catches typos early. Call `messages.reset()` to go back to the defaults.
|
|
333
|
+
|
|
334
|
+
## Development
|
|
335
|
+
|
|
336
|
+
```bash
|
|
337
|
+
python -m venv .venv
|
|
338
|
+
. .venv/bin/activate
|
|
339
|
+
pip install -e ".[dev]"
|
|
340
|
+
pytest
|
|
341
|
+
ruff check .
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
The tests use `httpx.MockTransport`, so they never touch the real API.
|
|
345
|
+
|
|
346
|
+
## Links
|
|
347
|
+
|
|
348
|
+
- Source code: [github.com/qiyascc/namecheap-python-sdk](https://github.com/qiyascc/namecheap-python-sdk)
|
|
349
|
+
- PyPI: [pypi.org/project/namecheap-python-sdk](https://pypi.org/project/namecheap-python-sdk/)
|
|
350
|
+
- Author: [Qiyas Sadıqov](https://theqiyas.cc), more projects at [theqiyas.cc](https://theqiyas.cc)
|
|
351
|
+
|
|
352
|
+
## License
|
|
353
|
+
|
|
354
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
namecheap_sdk/__init__.py,sha256=FUeNxbTX815BiMAnV8LlzCP5-xCdfa0W4j5lif27-ks,1032
|
|
2
|
+
namecheap_sdk/_base.py,sha256=8YsVg6p2XZNAgLYyhy5SolrBbJDEIr6JVIVGrnagi1w,8054
|
|
3
|
+
namecheap_sdk/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
4
|
+
namecheap_sdk/client.py,sha256=myETszCtvSdBfWN6TBFzWdwuMv2sExV6elYyarG7goI,954
|
|
5
|
+
namecheap_sdk/config.py,sha256=zTqEr1fB3lnYIZGPyuSjNES8vQmy0LtTKNuNtkrjF_U,4400
|
|
6
|
+
namecheap_sdk/errors.py,sha256=FmExbqjtwwuM5l3aFlUVVktP-dgSsLgFLgKv5L19giQ,4122
|
|
7
|
+
namecheap_sdk/messages.py,sha256=Us_YoaNd64bApY58c9gbphy22V8IZ9SKmJOepMGo5WU,1635
|
|
8
|
+
namecheap_sdk/messages.yaml,sha256=bCnfRsu6vhC6yZUT7mDi7Snk29NjAQzNwPrLp-9c7Ks,2286
|
|
9
|
+
namecheap_sdk/models.py,sha256=9C9P_lRJCrI2gtZmVytlizuISM6OJLzENC6tDoPhNKo,10989
|
|
10
|
+
namecheap_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
namecheap_sdk/resources/__init__.py,sha256=3Y8wYjChPxnydAf1oJfSz6Yrkn6jPZaSdJG_EB_8LDg,194
|
|
12
|
+
namecheap_sdk/resources/dns.py,sha256=4kP_I45I7uJJ16Xxlw1D34DJDBB8SVwv2MTYCJJHXLs,9650
|
|
13
|
+
namecheap_sdk/resources/domains.py,sha256=VLNBQoa8GXyp3ZzEIeZIuG0yWNa6mqpQX-kDlrzclR0,5738
|
|
14
|
+
namecheap_python_sdk-0.1.0.dist-info/METADATA,sha256=vWiuSbakzkBrMb6FQ8tmJOrvz2kBk1Kv4VTySzA6rxk,13851
|
|
15
|
+
namecheap_python_sdk-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
16
|
+
namecheap_python_sdk-0.1.0.dist-info/licenses/LICENSE,sha256=ENVL-_Eh4n1SumZuLuTjO2K0OOL_eXh4pkeme35b02I,1064
|
|
17
|
+
namecheap_python_sdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 qiyascc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from . import messages
|
|
2
|
+
from ._version import __version__
|
|
3
|
+
from .client import AsyncNamecheap, Namecheap
|
|
4
|
+
from .config import Settings, load_settings
|
|
5
|
+
from .errors import (
|
|
6
|
+
APIConnectionError,
|
|
7
|
+
APIError,
|
|
8
|
+
APIErrorDetail,
|
|
9
|
+
APITimeoutError,
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
NamecheapError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
PermissionDeniedError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
ServerError,
|
|
16
|
+
ValidationError,
|
|
17
|
+
)
|
|
18
|
+
from .models import Availability, Balances, DNSRecord, Domain, DomainInfo, Hosts, Nameservers, RecordType, Tld
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"__version__",
|
|
22
|
+
"Namecheap",
|
|
23
|
+
"AsyncNamecheap",
|
|
24
|
+
"Settings",
|
|
25
|
+
"load_settings",
|
|
26
|
+
"messages",
|
|
27
|
+
"Availability",
|
|
28
|
+
"Balances",
|
|
29
|
+
"DNSRecord",
|
|
30
|
+
"Domain",
|
|
31
|
+
"DomainInfo",
|
|
32
|
+
"Hosts",
|
|
33
|
+
"Nameservers",
|
|
34
|
+
"RecordType",
|
|
35
|
+
"Tld",
|
|
36
|
+
"NamecheapError",
|
|
37
|
+
"APIConnectionError",
|
|
38
|
+
"APITimeoutError",
|
|
39
|
+
"APIError",
|
|
40
|
+
"APIErrorDetail",
|
|
41
|
+
"ValidationError",
|
|
42
|
+
"AuthenticationError",
|
|
43
|
+
"PermissionDeniedError",
|
|
44
|
+
"NotFoundError",
|
|
45
|
+
"RateLimitError",
|
|
46
|
+
"ServerError",
|
|
47
|
+
]
|
namecheap_sdk/_base.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import random
|
|
5
|
+
import time
|
|
6
|
+
import xml.etree.ElementTree as ET
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Callable, Generator, Mapping, Optional, TypeVar
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .config import Settings, load_settings
|
|
13
|
+
from .errors import (
|
|
14
|
+
APIConnectionError,
|
|
15
|
+
APIError,
|
|
16
|
+
APIErrorDetail,
|
|
17
|
+
APITimeoutError,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
ServerError,
|
|
20
|
+
from_details,
|
|
21
|
+
from_status,
|
|
22
|
+
)
|
|
23
|
+
from .messages import text
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T")
|
|
26
|
+
|
|
27
|
+
def parse_xml(content: str) -> ET.Element:
|
|
28
|
+
try:
|
|
29
|
+
root = ET.fromstring(content)
|
|
30
|
+
except ET.ParseError as exc:
|
|
31
|
+
raise ServerError(text("error.bad_xml", error=exc)) from exc
|
|
32
|
+
for element in root.iter():
|
|
33
|
+
if isinstance(element.tag, str) and "}" in element.tag:
|
|
34
|
+
element.tag = element.tag.split("}", 1)[1]
|
|
35
|
+
return root
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Call:
|
|
40
|
+
command: str
|
|
41
|
+
params: Optional[Mapping[str, Any]] = None
|
|
42
|
+
parse: Callable[[ET.Element], Any] = lambda element: element
|
|
43
|
+
write: bool = False
|
|
44
|
+
|
|
45
|
+
def clean_params(self) -> dict[str, str]:
|
|
46
|
+
cleaned: dict[str, str] = {}
|
|
47
|
+
for key, value in (self.params or {}).items():
|
|
48
|
+
if value is None:
|
|
49
|
+
continue
|
|
50
|
+
if isinstance(value, bool):
|
|
51
|
+
cleaned[key] = "true" if value else "false"
|
|
52
|
+
elif isinstance(value, (list, tuple, set, frozenset)):
|
|
53
|
+
cleaned[key] = ",".join(str(v) for v in value)
|
|
54
|
+
else:
|
|
55
|
+
cleaned[key] = str(value)
|
|
56
|
+
return cleaned
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
Flow = Generator[Call, Any, T]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class BaseClient:
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
api_user: Optional[str] = None,
|
|
66
|
+
api_key: Optional[str] = None,
|
|
67
|
+
*,
|
|
68
|
+
client_ip: Optional[str] = None,
|
|
69
|
+
settings: Optional[Settings] = None,
|
|
70
|
+
config_file: Optional[str] = None,
|
|
71
|
+
**overrides: Any,
|
|
72
|
+
) -> None:
|
|
73
|
+
given = dict(overrides, api_user=api_user, api_key=api_key, client_ip=client_ip)
|
|
74
|
+
changes = {k: v for k, v in given.items() if v is not None}
|
|
75
|
+
if settings is None:
|
|
76
|
+
settings = load_settings(config_file, **changes)
|
|
77
|
+
elif changes:
|
|
78
|
+
settings = load_settings(None, **{**settings.model_dump(), **changes})
|
|
79
|
+
|
|
80
|
+
self.settings = settings
|
|
81
|
+
self._credentials = settings.require_credentials()
|
|
82
|
+
self.base_url = settings.resolved_base_url()
|
|
83
|
+
self.environment = settings.environment.lower()
|
|
84
|
+
self.timeout = settings.timeout
|
|
85
|
+
self.max_retries = settings.max_retries
|
|
86
|
+
self.max_retry_wait = settings.max_retry_wait
|
|
87
|
+
self.user_agent = settings.resolved_user_agent()
|
|
88
|
+
|
|
89
|
+
def __repr__(self) -> str:
|
|
90
|
+
return f"{type(self).__name__}(environment={self.environment!r}, base_url={self.base_url!r})"
|
|
91
|
+
|
|
92
|
+
def _headers(self) -> dict[str, str]:
|
|
93
|
+
return {"Accept": "application/xml", "User-Agent": self.user_agent}
|
|
94
|
+
|
|
95
|
+
def _request_args(self, call: Call) -> dict[str, Any]:
|
|
96
|
+
params = {**self._credentials, "Command": call.command, **call.clean_params()}
|
|
97
|
+
return {"data": params} if call.write else {"params": params}
|
|
98
|
+
|
|
99
|
+
def _wait_for(self, attempt: int, retry_after: Optional[float] = None) -> float:
|
|
100
|
+
if retry_after is not None:
|
|
101
|
+
return max(0.0, retry_after)
|
|
102
|
+
delay = self.settings.backoff_base * (2 ** attempt)
|
|
103
|
+
return min(self.settings.backoff_cap, delay) + random.uniform(0, self.settings.backoff_base / 2)
|
|
104
|
+
|
|
105
|
+
def _outcome(self, call: Call, response: httpx.Response) -> Any:
|
|
106
|
+
if response.status_code != 200:
|
|
107
|
+
raise from_status(response, call.command)
|
|
108
|
+
root = parse_xml(response.text)
|
|
109
|
+
if root.get("Status", "").upper() == "ERROR":
|
|
110
|
+
details = [APIErrorDetail(e.get("Number", ""), (e.text or "").strip())
|
|
111
|
+
for e in root.iterfind("./Errors/Error")]
|
|
112
|
+
raise from_details(details, call.command, response)
|
|
113
|
+
found = root.find("CommandResponse")
|
|
114
|
+
return call.parse(found if found is not None else root)
|
|
115
|
+
|
|
116
|
+
def _retry_wait(self, error: Exception, attempt: int) -> Optional[float]:
|
|
117
|
+
if attempt >= self.max_retries:
|
|
118
|
+
return None
|
|
119
|
+
if isinstance(error, RateLimitError):
|
|
120
|
+
wait = self._wait_for(attempt, error.retry_after)
|
|
121
|
+
return wait if wait <= self.max_retry_wait else None
|
|
122
|
+
if isinstance(error, (ServerError, APIConnectionError)):
|
|
123
|
+
return self._wait_for(attempt)
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
def _execute(self, call: Call) -> Any:
|
|
127
|
+
raise NotImplementedError
|
|
128
|
+
|
|
129
|
+
def _drive(self, flow: Flow[Any]) -> Any:
|
|
130
|
+
raise NotImplementedError
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class SyncClient(BaseClient):
|
|
134
|
+
def __init__(self, *args: Any, http_client: Optional[httpx.Client] = None, **kwargs: Any) -> None:
|
|
135
|
+
super().__init__(*args, **kwargs)
|
|
136
|
+
self._owns_http = http_client is None
|
|
137
|
+
self._http = http_client or httpx.Client(timeout=self.timeout)
|
|
138
|
+
|
|
139
|
+
def close(self) -> None:
|
|
140
|
+
if self._owns_http:
|
|
141
|
+
self._http.close()
|
|
142
|
+
|
|
143
|
+
def __enter__(self: T) -> T:
|
|
144
|
+
return self
|
|
145
|
+
|
|
146
|
+
def __exit__(self, *exc: Any) -> None:
|
|
147
|
+
self.close()
|
|
148
|
+
|
|
149
|
+
def _send(self, call: Call) -> httpx.Response:
|
|
150
|
+
method = "POST" if call.write else "GET"
|
|
151
|
+
try:
|
|
152
|
+
return self._http.request(method, self.base_url, headers=self._headers(), **self._request_args(call))
|
|
153
|
+
except httpx.TimeoutException as exc:
|
|
154
|
+
raise APITimeoutError(text("error.timeout", error=exc)) from exc
|
|
155
|
+
except httpx.TransportError as exc:
|
|
156
|
+
raise APIConnectionError(text("error.connection", error=exc)) from exc
|
|
157
|
+
|
|
158
|
+
def _execute(self, call: Call) -> Any:
|
|
159
|
+
attempt = 0
|
|
160
|
+
while True:
|
|
161
|
+
try:
|
|
162
|
+
return self._outcome(call, self._send(call))
|
|
163
|
+
except (APIError, APIConnectionError) as error:
|
|
164
|
+
wait = self._retry_wait(error, attempt)
|
|
165
|
+
if wait is None:
|
|
166
|
+
raise
|
|
167
|
+
time.sleep(wait)
|
|
168
|
+
attempt += 1
|
|
169
|
+
|
|
170
|
+
def _drive(self, flow: Flow[Any]) -> Any:
|
|
171
|
+
try:
|
|
172
|
+
call = next(flow)
|
|
173
|
+
while True:
|
|
174
|
+
call = flow.send(self._execute(call))
|
|
175
|
+
except StopIteration as stop:
|
|
176
|
+
return stop.value
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class AsyncClient(BaseClient):
|
|
180
|
+
def __init__(self, *args: Any, http_client: Optional[httpx.AsyncClient] = None, **kwargs: Any) -> None:
|
|
181
|
+
super().__init__(*args, **kwargs)
|
|
182
|
+
self._owns_http = http_client is None
|
|
183
|
+
self._http = http_client or httpx.AsyncClient(timeout=self.timeout)
|
|
184
|
+
|
|
185
|
+
async def close(self) -> None:
|
|
186
|
+
if self._owns_http:
|
|
187
|
+
await self._http.aclose()
|
|
188
|
+
|
|
189
|
+
async def __aenter__(self: T) -> T:
|
|
190
|
+
return self
|
|
191
|
+
|
|
192
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
193
|
+
await self.close()
|
|
194
|
+
|
|
195
|
+
async def _send(self, call: Call) -> httpx.Response:
|
|
196
|
+
method = "POST" if call.write else "GET"
|
|
197
|
+
try:
|
|
198
|
+
return await self._http.request(method, self.base_url, headers=self._headers(),
|
|
199
|
+
**self._request_args(call))
|
|
200
|
+
except httpx.TimeoutException as exc:
|
|
201
|
+
raise APITimeoutError(text("error.timeout", error=exc)) from exc
|
|
202
|
+
except httpx.TransportError as exc:
|
|
203
|
+
raise APIConnectionError(text("error.connection", error=exc)) from exc
|
|
204
|
+
|
|
205
|
+
async def _execute(self, call: Call) -> Any:
|
|
206
|
+
attempt = 0
|
|
207
|
+
while True:
|
|
208
|
+
try:
|
|
209
|
+
return self._outcome(call, await self._send(call))
|
|
210
|
+
except (APIError, APIConnectionError) as error:
|
|
211
|
+
wait = self._retry_wait(error, attempt)
|
|
212
|
+
if wait is None:
|
|
213
|
+
raise
|
|
214
|
+
await asyncio.sleep(wait)
|
|
215
|
+
attempt += 1
|
|
216
|
+
|
|
217
|
+
async def _drive(self, flow: Flow[Any]) -> Any:
|
|
218
|
+
try:
|
|
219
|
+
call = next(flow)
|
|
220
|
+
while True:
|
|
221
|
+
call = flow.send(await self._execute(call))
|
|
222
|
+
except StopIteration as stop:
|
|
223
|
+
return stop.value
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
__all__ = ["Call", "Flow", "SyncClient", "AsyncClient", "parse_xml"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|