namecheap-python 1.3.0__py3-none-any.whl → 1.5.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/__init__.py +7 -1
- namecheap/_api/domains.py +56 -139
- namecheap/_api/users.py +72 -2
- namecheap/_api/whoisguard.py +195 -0
- namecheap/client.py +8 -0
- namecheap/models.py +104 -0
- namecheap_cli/__main__.py +368 -0
- {namecheap_python-1.3.0.dist-info → namecheap_python-1.5.0.dist-info}/METADATA +66 -10
- {namecheap_python-1.3.0.dist-info → namecheap_python-1.5.0.dist-info}/RECORD +12 -11
- {namecheap_python-1.3.0.dist-info → namecheap_python-1.5.0.dist-info}/WHEEL +0 -0
- {namecheap_python-1.3.0.dist-info → namecheap_python-1.5.0.dist-info}/entry_points.txt +0 -0
- {namecheap_python-1.3.0.dist-info → namecheap_python-1.5.0.dist-info}/licenses/LICENSE +0 -0
namecheap/__init__.py
CHANGED
|
@@ -22,9 +22,12 @@ from .models import (
|
|
|
22
22
|
DomainInfo,
|
|
23
23
|
EmailForward,
|
|
24
24
|
Nameservers,
|
|
25
|
+
ProductPrice,
|
|
26
|
+
Tld,
|
|
27
|
+
WhoisguardEntry,
|
|
25
28
|
)
|
|
26
29
|
|
|
27
|
-
__version__ = "1.
|
|
30
|
+
__version__ = "1.5.0"
|
|
28
31
|
__all__ = [
|
|
29
32
|
"AccountBalance",
|
|
30
33
|
"ConfigurationError",
|
|
@@ -38,5 +41,8 @@ __all__ = [
|
|
|
38
41
|
"Namecheap",
|
|
39
42
|
"NamecheapError",
|
|
40
43
|
"Nameservers",
|
|
44
|
+
"ProductPrice",
|
|
45
|
+
"Tld",
|
|
41
46
|
"ValidationError",
|
|
47
|
+
"WhoisguardEntry",
|
|
42
48
|
]
|
namecheap/_api/domains.py
CHANGED
|
@@ -9,7 +9,14 @@ from typing import Any
|
|
|
9
9
|
import tldextract
|
|
10
10
|
|
|
11
11
|
from namecheap.logging import logger
|
|
12
|
-
from namecheap.models import
|
|
12
|
+
from namecheap.models import (
|
|
13
|
+
Contact,
|
|
14
|
+
Domain,
|
|
15
|
+
DomainCheck,
|
|
16
|
+
DomainContacts,
|
|
17
|
+
DomainInfo,
|
|
18
|
+
Tld,
|
|
19
|
+
)
|
|
13
20
|
|
|
14
21
|
from .base import BaseAPI
|
|
15
22
|
|
|
@@ -202,6 +209,34 @@ class DomainsAPI(BaseAPI):
|
|
|
202
209
|
aux_billing=parse_contact(result.get("AuxBilling", {})),
|
|
203
210
|
)
|
|
204
211
|
|
|
212
|
+
def get_tld_list(self) -> builtins.list[Tld]:
|
|
213
|
+
"""
|
|
214
|
+
Get list of all TLDs supported by Namecheap.
|
|
215
|
+
|
|
216
|
+
NOTE: Cache this response — it rarely changes and the API docs recommend it.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
List of Tld objects with registration/renewal constraints and capabilities
|
|
220
|
+
|
|
221
|
+
Examples:
|
|
222
|
+
>>> tlds = nc.domains.get_tld_list()
|
|
223
|
+
>>> registerable = [t for t in tlds if t.is_api_registerable]
|
|
224
|
+
>>> print(f"{len(registerable)} TLDs available for API registration")
|
|
225
|
+
"""
|
|
226
|
+
result: Any = self._request(
|
|
227
|
+
"namecheap.domains.getTldList",
|
|
228
|
+
path="Tlds",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
assert result, "API returned empty result for getTldList"
|
|
232
|
+
|
|
233
|
+
tlds = result.get("Tld", [])
|
|
234
|
+
if isinstance(tlds, dict):
|
|
235
|
+
tlds = [tlds]
|
|
236
|
+
assert isinstance(tlds, list), f"Unexpected Tld type: {type(tlds)}"
|
|
237
|
+
|
|
238
|
+
return [Tld.model_validate(t) for t in tlds]
|
|
239
|
+
|
|
205
240
|
def register(
|
|
206
241
|
self,
|
|
207
242
|
domain: str,
|
|
@@ -383,153 +418,35 @@ class DomainsAPI(BaseAPI):
|
|
|
383
418
|
def _get_pricing(
|
|
384
419
|
self, domains: builtins.list[str]
|
|
385
420
|
) -> dict[str, dict[str, Decimal | None]]:
|
|
386
|
-
"""
|
|
387
|
-
Get pricing information for domains.
|
|
421
|
+
"""Get 1-year registration pricing for a list of domains.
|
|
388
422
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
Returns:
|
|
393
|
-
Dict mapping domain to pricing info
|
|
423
|
+
Groups by TLD, fetches pricing via users.get_pricing(), and maps
|
|
424
|
+
back to individual domain names.
|
|
394
425
|
"""
|
|
395
|
-
pricing = {}
|
|
396
|
-
logger.debug(f"Getting pricing for domains: {domains}")
|
|
426
|
+
pricing: dict[str, dict[str, Decimal | None]] = {}
|
|
397
427
|
|
|
398
|
-
# Group domains by TLD
|
|
428
|
+
# Group domains by TLD
|
|
399
429
|
tld_groups: dict[str, builtins.list[str]] = {}
|
|
400
430
|
for domain in domains:
|
|
401
|
-
|
|
402
|
-
tld = ext.suffix
|
|
431
|
+
tld = tldextract.extract(domain).suffix
|
|
403
432
|
if tld not in tld_groups:
|
|
404
433
|
tld_groups[tld] = []
|
|
405
434
|
tld_groups[tld].append(domain)
|
|
406
435
|
|
|
407
|
-
logger.debug(f"TLD groups: {tld_groups}")
|
|
408
|
-
|
|
409
|
-
# Fetch pricing for each TLD group
|
|
410
436
|
for tld, domain_list in tld_groups.items():
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
logger.debug(f"Response type: {type(result)}")
|
|
426
|
-
logger.debug(
|
|
427
|
-
f"Response keys: "
|
|
428
|
-
f"{list(result.keys()) if isinstance(result, dict) else 'Not a dict'}"
|
|
429
|
-
)
|
|
430
|
-
|
|
431
|
-
# Extract pricing info
|
|
432
|
-
if isinstance(result, dict):
|
|
433
|
-
logger.debug(f"Parsing pricing response for {tld}")
|
|
434
|
-
|
|
435
|
-
# Get ProductCategory (could be a list or single dict)
|
|
436
|
-
categories = result.get("ProductCategory", {})
|
|
437
|
-
if not isinstance(categories, list):
|
|
438
|
-
categories = [categories] if categories else []
|
|
439
|
-
|
|
440
|
-
logger.debug(f"Found {len(categories)} categories")
|
|
441
|
-
|
|
442
|
-
# Look for REGISTER category
|
|
443
|
-
for category in categories:
|
|
444
|
-
if not isinstance(category, dict):
|
|
445
|
-
continue
|
|
446
|
-
|
|
447
|
-
# Use normalized name for consistent access
|
|
448
|
-
category_name = category.get("@Name", "")
|
|
449
|
-
category_name_normalized = category.get(
|
|
450
|
-
"@Name_normalized", category_name.lower()
|
|
451
|
-
)
|
|
452
|
-
logger.debug(
|
|
453
|
-
f"Checking category: {category_name} "
|
|
454
|
-
f"(normalized: {category_name_normalized})"
|
|
455
|
-
)
|
|
456
|
-
|
|
457
|
-
if category_name_normalized == "register":
|
|
458
|
-
# Get products in this category
|
|
459
|
-
products = category.get("Product", {})
|
|
460
|
-
if not isinstance(products, list):
|
|
461
|
-
products = [products] if products else []
|
|
462
|
-
|
|
463
|
-
logger.debug(
|
|
464
|
-
f"Found {len(products)} products in REGISTER category"
|
|
465
|
-
)
|
|
466
|
-
|
|
467
|
-
# Find the product matching our TLD
|
|
468
|
-
for product in products:
|
|
469
|
-
if not isinstance(product, dict):
|
|
470
|
-
continue
|
|
471
|
-
|
|
472
|
-
product_name = product.get("@Name", "")
|
|
473
|
-
logger.debug(
|
|
474
|
-
f"Checking product: {product_name} vs {tld}"
|
|
475
|
-
)
|
|
476
|
-
|
|
477
|
-
if product_name.lower() == tld.lower():
|
|
478
|
-
# Get price list
|
|
479
|
-
price_info = product.get("Price", [])
|
|
480
|
-
if not isinstance(price_info, list):
|
|
481
|
-
price_info = [price_info] if price_info else []
|
|
482
|
-
|
|
483
|
-
logger.debug(
|
|
484
|
-
f"Found {len(price_info)} price entries for {tld}"
|
|
485
|
-
)
|
|
486
|
-
|
|
487
|
-
# Find 1 year price
|
|
488
|
-
for price in price_info:
|
|
489
|
-
if not isinstance(price, dict):
|
|
490
|
-
continue
|
|
491
|
-
|
|
492
|
-
duration = price.get("@Duration", "")
|
|
493
|
-
if duration == "1":
|
|
494
|
-
regular_price = price.get("@RegularPrice")
|
|
495
|
-
your_price = price.get("@YourPrice")
|
|
496
|
-
retail_price = price.get("@RetailPrice")
|
|
497
|
-
|
|
498
|
-
# Get additional cost
|
|
499
|
-
# (normalization handles their typo)
|
|
500
|
-
price.get("@YourAdditionalCost", "0")
|
|
501
|
-
|
|
502
|
-
logger.debug(
|
|
503
|
-
f"Found prices for {tld}: "
|
|
504
|
-
f"regular={regular_price}, "
|
|
505
|
-
f"your={your_price}, "
|
|
506
|
-
f"retail={retail_price}"
|
|
507
|
-
)
|
|
508
|
-
|
|
509
|
-
# Apply to all domains with this TLD
|
|
510
|
-
for domain in domain_list:
|
|
511
|
-
pricing[domain] = {
|
|
512
|
-
"regular_price": Decimal(
|
|
513
|
-
regular_price
|
|
514
|
-
)
|
|
515
|
-
if regular_price
|
|
516
|
-
else None,
|
|
517
|
-
"your_price": Decimal(your_price)
|
|
518
|
-
if your_price
|
|
519
|
-
else None,
|
|
520
|
-
"retail_price": Decimal(
|
|
521
|
-
retail_price
|
|
522
|
-
)
|
|
523
|
-
if retail_price
|
|
524
|
-
else None,
|
|
525
|
-
}
|
|
526
|
-
break
|
|
527
|
-
break
|
|
528
|
-
break
|
|
529
|
-
|
|
530
|
-
except Exception as e:
|
|
531
|
-
# If pricing fails, continue without it
|
|
532
|
-
logger.error(f"Failed to get pricing for TLD {tld}: {e}")
|
|
533
|
-
logger.debug("Full error:", exc_info=True)
|
|
437
|
+
result = self.client.users.get_pricing(
|
|
438
|
+
"DOMAIN", action="REGISTER", product_name=tld
|
|
439
|
+
)
|
|
440
|
+
prices = result.get("REGISTER", {}).get(tld, [])
|
|
441
|
+
|
|
442
|
+
for p in prices:
|
|
443
|
+
if p.duration == 1:
|
|
444
|
+
for domain in domain_list:
|
|
445
|
+
pricing[domain] = {
|
|
446
|
+
"regular_price": p.regular_price,
|
|
447
|
+
"your_price": p.your_price,
|
|
448
|
+
"retail_price": None,
|
|
449
|
+
}
|
|
450
|
+
break
|
|
534
451
|
|
|
535
452
|
return pricing
|
namecheap/_api/users.py
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import Any
|
|
5
|
+
from typing import Any, Literal
|
|
6
6
|
|
|
7
|
-
from namecheap.models import AccountBalance
|
|
7
|
+
from namecheap.models import AccountBalance, ProductPrice
|
|
8
8
|
|
|
9
9
|
from .base import BaseAPI
|
|
10
10
|
|
|
@@ -30,3 +30,73 @@ class UsersAPI(BaseAPI):
|
|
|
30
30
|
|
|
31
31
|
assert result, "API returned empty result for getBalances"
|
|
32
32
|
return AccountBalance.model_validate(result)
|
|
33
|
+
|
|
34
|
+
def get_pricing(
|
|
35
|
+
self,
|
|
36
|
+
product_type: Literal["DOMAIN", "SSLCERTIFICATE"] = "DOMAIN",
|
|
37
|
+
*,
|
|
38
|
+
action: str | None = None,
|
|
39
|
+
product_name: str | None = None,
|
|
40
|
+
) -> dict[str, dict[str, list[ProductPrice]]]:
|
|
41
|
+
"""
|
|
42
|
+
Get pricing for products.
|
|
43
|
+
|
|
44
|
+
Returns a nested dict: {action: {product: [prices]}}.
|
|
45
|
+
For example: {"REGISTER": {"com": [ProductPrice(duration=1, ...), ...]}}
|
|
46
|
+
|
|
47
|
+
NOTE: Cache this response — Namecheap recommends it.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
product_type: "DOMAIN" or "SSLCERTIFICATE"
|
|
51
|
+
action: Filter by action (REGISTER, RENEW, TRANSFER, REACTIVATE)
|
|
52
|
+
product_name: Filter by product/TLD name (e.g., "com")
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Nested dict of action -> product -> list of ProductPrice
|
|
56
|
+
|
|
57
|
+
Examples:
|
|
58
|
+
>>> pricing = nc.users.get_pricing("DOMAIN", action="REGISTER", product_name="com")
|
|
59
|
+
>>> prices = pricing["REGISTER"]["com"]
|
|
60
|
+
>>> print(f"1-year .com: ${prices[0].your_price}")
|
|
61
|
+
"""
|
|
62
|
+
params: dict[str, Any] = {"ProductType": product_type}
|
|
63
|
+
if action:
|
|
64
|
+
params["ActionName"] = action
|
|
65
|
+
if product_name:
|
|
66
|
+
params["ProductName"] = product_name
|
|
67
|
+
|
|
68
|
+
result: Any = self._request(
|
|
69
|
+
"namecheap.users.getPricing",
|
|
70
|
+
params,
|
|
71
|
+
path="UserGetPricingResult.ProductType",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
assert result, "API returned empty result for getPricing"
|
|
75
|
+
|
|
76
|
+
pricing: dict[str, dict[str, list[ProductPrice]]] = {}
|
|
77
|
+
|
|
78
|
+
categories = result.get("ProductCategory", [])
|
|
79
|
+
if isinstance(categories, dict):
|
|
80
|
+
categories = [categories]
|
|
81
|
+
|
|
82
|
+
for category in categories:
|
|
83
|
+
action_name = category.get("@Name", "").upper()
|
|
84
|
+
|
|
85
|
+
products = category.get("Product", [])
|
|
86
|
+
if isinstance(products, dict):
|
|
87
|
+
products = [products]
|
|
88
|
+
|
|
89
|
+
for product in products:
|
|
90
|
+
name = product.get("@Name", "").lower()
|
|
91
|
+
|
|
92
|
+
prices = product.get("Price", [])
|
|
93
|
+
if isinstance(prices, dict):
|
|
94
|
+
prices = [prices]
|
|
95
|
+
|
|
96
|
+
parsed = [ProductPrice.model_validate(p) for p in prices]
|
|
97
|
+
|
|
98
|
+
if action_name not in pricing:
|
|
99
|
+
pricing[action_name] = {}
|
|
100
|
+
pricing[action_name][name] = parsed
|
|
101
|
+
|
|
102
|
+
return pricing
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Domain privacy (WhoisGuard) API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from namecheap.models import WhoisguardEntry
|
|
9
|
+
|
|
10
|
+
from .base import BaseAPI
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WhoisguardAPI(BaseAPI):
|
|
14
|
+
"""Domain privacy (WhoisGuard) management.
|
|
15
|
+
|
|
16
|
+
The Namecheap API uses WhoisGuard IDs internally, but this class
|
|
17
|
+
provides domain-name-based convenience methods that resolve the ID
|
|
18
|
+
automatically via get_list().
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def get_list(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
list_type: Literal["ALL", "ALLOTED", "FREE", "DISCARD"] = "ALL",
|
|
25
|
+
page: int = 1,
|
|
26
|
+
page_size: int = 100,
|
|
27
|
+
) -> list[WhoisguardEntry]:
|
|
28
|
+
"""
|
|
29
|
+
Get all WhoisGuard subscriptions.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
list_type: Filter type (ALL, ALLOTED, FREE, DISCARD)
|
|
33
|
+
page: Page number
|
|
34
|
+
page_size: Items per page (2-100)
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
List of WhoisguardEntry subscriptions
|
|
38
|
+
|
|
39
|
+
Examples:
|
|
40
|
+
>>> entries = nc.whoisguard.get_list()
|
|
41
|
+
>>> for e in entries:
|
|
42
|
+
... print(f"{e.domain} (ID={e.id}) status={e.status}")
|
|
43
|
+
"""
|
|
44
|
+
result: Any = self._request(
|
|
45
|
+
"namecheap.whoisguard.getList",
|
|
46
|
+
{
|
|
47
|
+
"ListType": list_type,
|
|
48
|
+
"Page": page,
|
|
49
|
+
"PageSize": min(page_size, 100),
|
|
50
|
+
},
|
|
51
|
+
path="WhoisguardGetListResult",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if not result:
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
entries = result.get("Whoisguard", [])
|
|
58
|
+
if isinstance(entries, dict):
|
|
59
|
+
entries = [entries]
|
|
60
|
+
assert isinstance(entries, list), f"Unexpected Whoisguard type: {type(entries)}"
|
|
61
|
+
|
|
62
|
+
return [WhoisguardEntry.model_validate(e) for e in entries]
|
|
63
|
+
|
|
64
|
+
def _resolve_id(self, domain: str) -> int:
|
|
65
|
+
"""Resolve a domain name to its WhoisGuard ID."""
|
|
66
|
+
entries = self.get_list(list_type="ALLOTED")
|
|
67
|
+
for entry in entries:
|
|
68
|
+
if entry.domain.lower() == domain.lower():
|
|
69
|
+
return entry.id
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"No WhoisGuard subscription found for {domain}. "
|
|
72
|
+
f"Domain must have WhoisGuard allotted to enable/disable it."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def enable(self, domain: str, forwarded_to_email: str) -> bool:
|
|
76
|
+
"""
|
|
77
|
+
Enable domain privacy for a domain.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
domain: Domain name (resolved to WhoisGuard ID automatically)
|
|
81
|
+
forwarded_to_email: Email where privacy-masked emails get forwarded
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
True if successful
|
|
85
|
+
|
|
86
|
+
Examples:
|
|
87
|
+
>>> nc.whoisguard.enable("example.com", "me@gmail.com")
|
|
88
|
+
"""
|
|
89
|
+
wg_id = self._resolve_id(domain)
|
|
90
|
+
|
|
91
|
+
result: Any = self._request(
|
|
92
|
+
"namecheap.whoisguard.enable",
|
|
93
|
+
{
|
|
94
|
+
"WhoisguardID": wg_id,
|
|
95
|
+
"ForwardedToEmail": forwarded_to_email,
|
|
96
|
+
},
|
|
97
|
+
path="WhoisguardEnableResult",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
assert result, f"API returned empty result for whoisguard.enable on {domain}"
|
|
101
|
+
return result.get("@IsSuccess", "false").lower() == "true"
|
|
102
|
+
|
|
103
|
+
def disable(self, domain: str) -> bool:
|
|
104
|
+
"""
|
|
105
|
+
Disable domain privacy for a domain.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
domain: Domain name (resolved to WhoisGuard ID automatically)
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
True if successful
|
|
112
|
+
|
|
113
|
+
Examples:
|
|
114
|
+
>>> nc.whoisguard.disable("example.com")
|
|
115
|
+
"""
|
|
116
|
+
wg_id = self._resolve_id(domain)
|
|
117
|
+
|
|
118
|
+
result: Any = self._request(
|
|
119
|
+
"namecheap.whoisguard.disable",
|
|
120
|
+
{"WhoisguardID": wg_id},
|
|
121
|
+
path="WhoisguardDisableResult",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
assert result, f"API returned empty result for whoisguard.disable on {domain}"
|
|
125
|
+
return result.get("@IsSuccess", "false").lower() == "true"
|
|
126
|
+
|
|
127
|
+
def renew(self, domain: str, *, years: int = 1) -> dict[str, Any]:
|
|
128
|
+
"""
|
|
129
|
+
Renew domain privacy for a domain.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
domain: Domain name (resolved to WhoisGuard ID automatically)
|
|
133
|
+
years: Number of years to renew (1-9)
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Dict with OrderId, TransactionId, ChargedAmount
|
|
137
|
+
|
|
138
|
+
Examples:
|
|
139
|
+
>>> result = nc.whoisguard.renew("example.com", years=1)
|
|
140
|
+
>>> print(f"Charged: {result['charged_amount']}")
|
|
141
|
+
"""
|
|
142
|
+
assert 1 <= years <= 9, "Years must be between 1 and 9"
|
|
143
|
+
wg_id = self._resolve_id(domain)
|
|
144
|
+
|
|
145
|
+
result: Any = self._request(
|
|
146
|
+
"namecheap.whoisguard.renew",
|
|
147
|
+
{
|
|
148
|
+
"WhoisguardID": wg_id,
|
|
149
|
+
"Years": years,
|
|
150
|
+
},
|
|
151
|
+
path="WhoisguardRenewResult",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
assert result, f"API returned empty result for whoisguard.renew on {domain}"
|
|
155
|
+
return {
|
|
156
|
+
"whoisguard_id": int(result.get("@WhoisguardId", wg_id)),
|
|
157
|
+
"years": int(result.get("@Years", years)),
|
|
158
|
+
"is_renewed": result.get("@Renew", "false").lower() == "true",
|
|
159
|
+
"order_id": int(result.get("@OrderId", 0)),
|
|
160
|
+
"transaction_id": int(result.get("@TransactionId", 0)),
|
|
161
|
+
"charged_amount": Decimal(result.get("@ChargedAmount", "0")),
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
def change_email(self, domain: str) -> dict[str, str]:
|
|
165
|
+
"""
|
|
166
|
+
Rotate the privacy forwarding email address for a domain.
|
|
167
|
+
|
|
168
|
+
Namecheap generates a new masked email and retires the old one.
|
|
169
|
+
No input email needed — the API handles the rotation.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
domain: Domain name (resolved to WhoisGuard ID automatically)
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Dict with new_email and old_email
|
|
176
|
+
|
|
177
|
+
Examples:
|
|
178
|
+
>>> result = nc.whoisguard.change_email("example.com")
|
|
179
|
+
>>> print(f"New: {result['new_email']}")
|
|
180
|
+
"""
|
|
181
|
+
wg_id = self._resolve_id(domain)
|
|
182
|
+
|
|
183
|
+
result: Any = self._request(
|
|
184
|
+
"namecheap.whoisguard.changeEmailAddress",
|
|
185
|
+
{"WhoisguardID": wg_id},
|
|
186
|
+
path="WhoisguardChangeEmailAddressResult",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
assert result, (
|
|
190
|
+
f"API returned empty result for whoisguard.changeEmailAddress on {domain}"
|
|
191
|
+
)
|
|
192
|
+
return {
|
|
193
|
+
"new_email": result.get("@WGEmail", ""),
|
|
194
|
+
"old_email": result.get("@WGOldEmail", ""),
|
|
195
|
+
}
|
namecheap/client.py
CHANGED
|
@@ -17,6 +17,7 @@ if TYPE_CHECKING:
|
|
|
17
17
|
from ._api.dns import DnsAPI
|
|
18
18
|
from ._api.domains import DomainsAPI
|
|
19
19
|
from ._api.users import UsersAPI
|
|
20
|
+
from ._api.whoisguard import WhoisguardAPI
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
class Namecheap:
|
|
@@ -111,6 +112,13 @@ class Namecheap:
|
|
|
111
112
|
|
|
112
113
|
return DnsAPI(self)
|
|
113
114
|
|
|
115
|
+
@cached_property
|
|
116
|
+
def whoisguard(self) -> WhoisguardAPI:
|
|
117
|
+
"""Domain privacy (WhoisGuard) management."""
|
|
118
|
+
from ._api.whoisguard import WhoisguardAPI
|
|
119
|
+
|
|
120
|
+
return WhoisguardAPI(self)
|
|
121
|
+
|
|
114
122
|
@cached_property
|
|
115
123
|
def users(self) -> UsersAPI:
|
|
116
124
|
"""User account operations."""
|
namecheap/models.py
CHANGED
|
@@ -347,6 +347,110 @@ class DomainContacts(BaseModel):
|
|
|
347
347
|
model_config = ConfigDict(populate_by_name=True)
|
|
348
348
|
|
|
349
349
|
|
|
350
|
+
class Tld(BaseModel):
|
|
351
|
+
"""TLD information from Namecheap's supported TLD list."""
|
|
352
|
+
|
|
353
|
+
name: str = Field(alias="@Name")
|
|
354
|
+
description: str = Field(alias="#text", default="")
|
|
355
|
+
type: str = Field(alias="@Type")
|
|
356
|
+
min_register_years: int = Field(alias="@MinRegisterYears")
|
|
357
|
+
max_register_years: int = Field(alias="@MaxRegisterYears")
|
|
358
|
+
min_renew_years: int = Field(alias="@MinRenewYears")
|
|
359
|
+
max_renew_years: int = Field(alias="@MaxRenewYears")
|
|
360
|
+
min_transfer_years: int = Field(alias="@MinTransferYears")
|
|
361
|
+
max_transfer_years: int = Field(alias="@MaxTransferYears")
|
|
362
|
+
is_api_registerable: bool = Field(alias="@IsApiRegisterable")
|
|
363
|
+
is_api_renewable: bool = Field(alias="@IsApiRenewable")
|
|
364
|
+
is_api_transferable: bool = Field(alias="@IsApiTransferable")
|
|
365
|
+
is_epp_required: bool = Field(alias="@IsEppRequired")
|
|
366
|
+
is_disable_mod_contact: bool = Field(alias="@IsDisableModContact")
|
|
367
|
+
is_disable_wg_allot: bool = Field(alias="@IsDisableWGAllot")
|
|
368
|
+
is_supports_idn: bool = Field(alias="@IsSupportsIDN")
|
|
369
|
+
category: str = Field(alias="@Category", default="")
|
|
370
|
+
sequence_number: int = Field(alias="@SequenceNumber", default=0)
|
|
371
|
+
non_real_time: bool = Field(alias="@NonRealTime", default=False)
|
|
372
|
+
|
|
373
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
374
|
+
|
|
375
|
+
@field_validator(
|
|
376
|
+
"is_api_registerable",
|
|
377
|
+
"is_api_renewable",
|
|
378
|
+
"is_api_transferable",
|
|
379
|
+
"is_epp_required",
|
|
380
|
+
"is_disable_mod_contact",
|
|
381
|
+
"is_disable_wg_allot",
|
|
382
|
+
"is_supports_idn",
|
|
383
|
+
"non_real_time",
|
|
384
|
+
mode="before",
|
|
385
|
+
)
|
|
386
|
+
@classmethod
|
|
387
|
+
def parse_bool(cls, v: Any) -> bool:
|
|
388
|
+
if isinstance(v, bool):
|
|
389
|
+
return v
|
|
390
|
+
if isinstance(v, str):
|
|
391
|
+
return v.lower() == "true"
|
|
392
|
+
return False
|
|
393
|
+
|
|
394
|
+
@field_validator(
|
|
395
|
+
"min_register_years",
|
|
396
|
+
"max_register_years",
|
|
397
|
+
"min_renew_years",
|
|
398
|
+
"max_renew_years",
|
|
399
|
+
"min_transfer_years",
|
|
400
|
+
"max_transfer_years",
|
|
401
|
+
"sequence_number",
|
|
402
|
+
mode="before",
|
|
403
|
+
)
|
|
404
|
+
@classmethod
|
|
405
|
+
def parse_int(cls, v: Any) -> int:
|
|
406
|
+
return int(v) if v else 0
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class WhoisguardEntry(BaseModel):
|
|
410
|
+
"""A WhoisGuard/domain privacy subscription."""
|
|
411
|
+
|
|
412
|
+
id: int = Field(alias="@ID")
|
|
413
|
+
domain: str = Field(alias="@DomainName", default="")
|
|
414
|
+
created: str = Field(alias="@Created", default="")
|
|
415
|
+
expires: str = Field(alias="@Expires", default="")
|
|
416
|
+
status: str = Field(alias="@Status", default="")
|
|
417
|
+
|
|
418
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
419
|
+
|
|
420
|
+
@field_validator("id", mode="before")
|
|
421
|
+
@classmethod
|
|
422
|
+
def parse_id(cls, v: Any) -> int:
|
|
423
|
+
return int(v) if v else 0
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class ProductPrice(BaseModel):
|
|
427
|
+
"""A single price entry for a product at a specific duration."""
|
|
428
|
+
|
|
429
|
+
duration: int = Field(alias="@Duration")
|
|
430
|
+
duration_type: str = Field(alias="@DurationType", default="YEAR")
|
|
431
|
+
price: Decimal = Field(alias="@Price")
|
|
432
|
+
regular_price: Decimal = Field(alias="@RegularPrice")
|
|
433
|
+
your_price: Decimal = Field(alias="@YourPrice")
|
|
434
|
+
coupon_price: Decimal | None = Field(alias="@CouponPrice", default=None)
|
|
435
|
+
currency: str = Field(alias="@Currency", default="USD")
|
|
436
|
+
|
|
437
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
438
|
+
|
|
439
|
+
@field_validator(
|
|
440
|
+
"price", "regular_price", "your_price", "coupon_price", mode="before"
|
|
441
|
+
)
|
|
442
|
+
@classmethod
|
|
443
|
+
def parse_decimal(cls, v: Any) -> Decimal | None:
|
|
444
|
+
if v is None or v == "":
|
|
445
|
+
return None
|
|
446
|
+
return Decimal(str(v))
|
|
447
|
+
|
|
448
|
+
@field_validator("duration", mode="before")
|
|
449
|
+
@classmethod
|
|
450
|
+
def parse_duration(cls, v: Any) -> int:
|
|
451
|
+
return int(v) if v else 1
|
|
452
|
+
|
|
453
|
+
|
|
350
454
|
class Config(BaseModel):
|
|
351
455
|
"""Client configuration with validation."""
|
|
352
456
|
|
namecheap_cli/__main__.py
CHANGED
|
@@ -1016,6 +1016,293 @@ def domain_contacts(config: Config, domain: str) -> None:
|
|
|
1016
1016
|
sys.exit(1)
|
|
1017
1017
|
|
|
1018
1018
|
|
|
1019
|
+
@domain_group.command("tlds")
|
|
1020
|
+
@click.option("--registerable", is_flag=True, help="Only show API-registerable TLDs")
|
|
1021
|
+
@click.option(
|
|
1022
|
+
"--type",
|
|
1023
|
+
"-t",
|
|
1024
|
+
"tld_type",
|
|
1025
|
+
type=click.Choice(["GTLD", "CCTLD"], case_sensitive=False),
|
|
1026
|
+
help="Filter by TLD type",
|
|
1027
|
+
)
|
|
1028
|
+
@pass_config
|
|
1029
|
+
def domain_tlds(config: Config, registerable: bool, tld_type: str | None) -> None:
|
|
1030
|
+
"""List all supported TLDs."""
|
|
1031
|
+
nc = config.init_client()
|
|
1032
|
+
|
|
1033
|
+
try:
|
|
1034
|
+
with Progress(
|
|
1035
|
+
SpinnerColumn(),
|
|
1036
|
+
TextColumn("[progress.description]{task.description}"),
|
|
1037
|
+
transient=True,
|
|
1038
|
+
) as progress:
|
|
1039
|
+
progress.add_task("Loading TLD list...", total=None)
|
|
1040
|
+
tlds = nc.domains.get_tld_list()
|
|
1041
|
+
|
|
1042
|
+
if registerable:
|
|
1043
|
+
tlds = [t for t in tlds if t.is_api_registerable]
|
|
1044
|
+
if tld_type:
|
|
1045
|
+
tlds = [t for t in tlds if t.type.upper() == tld_type.upper()]
|
|
1046
|
+
|
|
1047
|
+
tlds.sort(key=lambda t: t.name)
|
|
1048
|
+
|
|
1049
|
+
if config.output_format == "table":
|
|
1050
|
+
table = Table(title=f"Supported TLDs ({len(tlds)} total)")
|
|
1051
|
+
table.add_column("TLD", style="cyan")
|
|
1052
|
+
table.add_column("Type", style="magenta")
|
|
1053
|
+
table.add_column("Description", style="dim")
|
|
1054
|
+
table.add_column("Register", justify="center")
|
|
1055
|
+
table.add_column("Renew", justify="center")
|
|
1056
|
+
table.add_column("Transfer", justify="center")
|
|
1057
|
+
table.add_column("Years", justify="center")
|
|
1058
|
+
|
|
1059
|
+
for t in tlds:
|
|
1060
|
+
table.add_row(
|
|
1061
|
+
f".{t.name}",
|
|
1062
|
+
t.type,
|
|
1063
|
+
t.description[:40] if t.description else "",
|
|
1064
|
+
"✓" if t.is_api_registerable else "✗",
|
|
1065
|
+
"✓" if t.is_api_renewable else "✗",
|
|
1066
|
+
"✓" if t.is_api_transferable else "✗",
|
|
1067
|
+
f"{t.min_register_years}-{t.max_register_years}",
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
console.print(table)
|
|
1071
|
+
else:
|
|
1072
|
+
data = [
|
|
1073
|
+
{
|
|
1074
|
+
"tld": t.name,
|
|
1075
|
+
"type": t.type,
|
|
1076
|
+
"description": t.description,
|
|
1077
|
+
"api_registerable": t.is_api_registerable,
|
|
1078
|
+
"api_renewable": t.is_api_renewable,
|
|
1079
|
+
"api_transferable": t.is_api_transferable,
|
|
1080
|
+
}
|
|
1081
|
+
for t in tlds
|
|
1082
|
+
]
|
|
1083
|
+
output_formatter(data, config.output_format)
|
|
1084
|
+
|
|
1085
|
+
except NamecheapError as e:
|
|
1086
|
+
console.print(f"[red]❌ Error: {e}[/red]")
|
|
1087
|
+
sys.exit(1)
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
@cli.group("privacy")
|
|
1091
|
+
def privacy_group() -> None:
|
|
1092
|
+
"""Domain privacy (WhoisGuard) management."""
|
|
1093
|
+
pass
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
@privacy_group.command("list")
|
|
1097
|
+
@click.option(
|
|
1098
|
+
"--type",
|
|
1099
|
+
"-t",
|
|
1100
|
+
"list_type",
|
|
1101
|
+
type=click.Choice(["ALL", "ALLOTED", "FREE", "DISCARD"], case_sensitive=False),
|
|
1102
|
+
default="ALL",
|
|
1103
|
+
help="Filter by subscription type",
|
|
1104
|
+
)
|
|
1105
|
+
@pass_config
|
|
1106
|
+
def privacy_list(config: Config, list_type: str) -> None:
|
|
1107
|
+
"""List WhoisGuard subscriptions."""
|
|
1108
|
+
nc = config.init_client()
|
|
1109
|
+
|
|
1110
|
+
try:
|
|
1111
|
+
with Progress(
|
|
1112
|
+
SpinnerColumn(),
|
|
1113
|
+
TextColumn("[progress.description]{task.description}"),
|
|
1114
|
+
transient=True,
|
|
1115
|
+
) as progress:
|
|
1116
|
+
progress.add_task("Loading WhoisGuard subscriptions...", total=None)
|
|
1117
|
+
entries = nc.whoisguard.get_list(list_type=list_type.upper())
|
|
1118
|
+
|
|
1119
|
+
if config.output_format == "table":
|
|
1120
|
+
table = Table(title=f"WhoisGuard Subscriptions ({len(entries)} total)")
|
|
1121
|
+
table.add_column("ID", style="dim")
|
|
1122
|
+
table.add_column("Domain", style="cyan")
|
|
1123
|
+
table.add_column("Status", style="green")
|
|
1124
|
+
table.add_column("Created", style="yellow")
|
|
1125
|
+
table.add_column("Expires", style="yellow")
|
|
1126
|
+
|
|
1127
|
+
for e in entries:
|
|
1128
|
+
status_style = (
|
|
1129
|
+
"green" if e.status.lower() in ("enabled", "alloted") else "yellow"
|
|
1130
|
+
)
|
|
1131
|
+
table.add_row(
|
|
1132
|
+
str(e.id),
|
|
1133
|
+
e.domain or "[dim]unassigned[/dim]",
|
|
1134
|
+
f"[{status_style}]{e.status}[/{status_style}]",
|
|
1135
|
+
e.created,
|
|
1136
|
+
e.expires,
|
|
1137
|
+
)
|
|
1138
|
+
|
|
1139
|
+
console.print(table)
|
|
1140
|
+
else:
|
|
1141
|
+
output_formatter([e.model_dump() for e in entries], config.output_format)
|
|
1142
|
+
|
|
1143
|
+
except NamecheapError as e:
|
|
1144
|
+
console.print(f"[red]❌ Error: {e}[/red]")
|
|
1145
|
+
sys.exit(1)
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
@privacy_group.command("enable")
|
|
1149
|
+
@click.argument("domain")
|
|
1150
|
+
@click.argument("email")
|
|
1151
|
+
@pass_config
|
|
1152
|
+
def privacy_enable(config: Config, domain: str, email: str) -> None:
|
|
1153
|
+
"""Enable domain privacy. Requires forwarding email.
|
|
1154
|
+
|
|
1155
|
+
Example:
|
|
1156
|
+
namecheap-cli privacy enable example.com me@gmail.com
|
|
1157
|
+
"""
|
|
1158
|
+
nc = config.init_client()
|
|
1159
|
+
|
|
1160
|
+
try:
|
|
1161
|
+
with Progress(
|
|
1162
|
+
SpinnerColumn(),
|
|
1163
|
+
TextColumn("[progress.description]{task.description}"),
|
|
1164
|
+
transient=True,
|
|
1165
|
+
) as progress:
|
|
1166
|
+
progress.add_task(f"Enabling privacy for {domain}...", total=None)
|
|
1167
|
+
success = nc.whoisguard.enable(domain, email)
|
|
1168
|
+
|
|
1169
|
+
if success:
|
|
1170
|
+
console.print(f"[green]✅ Privacy enabled for {domain}[/green]")
|
|
1171
|
+
console.print(f"[dim]Forwarding email: {email}[/dim]")
|
|
1172
|
+
else:
|
|
1173
|
+
console.print("[red]❌ Failed to enable privacy[/red]")
|
|
1174
|
+
sys.exit(1)
|
|
1175
|
+
|
|
1176
|
+
except ValueError as e:
|
|
1177
|
+
console.print(f"[red]❌ {e}[/red]")
|
|
1178
|
+
sys.exit(1)
|
|
1179
|
+
except NamecheapError as e:
|
|
1180
|
+
console.print(f"[red]❌ Error: {e}[/red]")
|
|
1181
|
+
sys.exit(1)
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
@privacy_group.command("disable")
|
|
1185
|
+
@click.argument("domain")
|
|
1186
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
|
|
1187
|
+
@pass_config
|
|
1188
|
+
def privacy_disable(config: Config, domain: str, yes: bool) -> None:
|
|
1189
|
+
"""Disable domain privacy.
|
|
1190
|
+
|
|
1191
|
+
Example:
|
|
1192
|
+
namecheap-cli privacy disable example.com
|
|
1193
|
+
"""
|
|
1194
|
+
nc = config.init_client()
|
|
1195
|
+
|
|
1196
|
+
try:
|
|
1197
|
+
if not yes and not config.quiet:
|
|
1198
|
+
console.print(
|
|
1199
|
+
f"\n[yellow]This will expose your WHOIS information for {domain}.[/yellow]"
|
|
1200
|
+
)
|
|
1201
|
+
if not Confirm.ask("Continue?", default=False):
|
|
1202
|
+
console.print("[yellow]Cancelled[/yellow]")
|
|
1203
|
+
return
|
|
1204
|
+
|
|
1205
|
+
with Progress(
|
|
1206
|
+
SpinnerColumn(),
|
|
1207
|
+
TextColumn("[progress.description]{task.description}"),
|
|
1208
|
+
transient=True,
|
|
1209
|
+
) as progress:
|
|
1210
|
+
progress.add_task(f"Disabling privacy for {domain}...", total=None)
|
|
1211
|
+
success = nc.whoisguard.disable(domain)
|
|
1212
|
+
|
|
1213
|
+
if success:
|
|
1214
|
+
console.print(f"[green]✅ Privacy disabled for {domain}[/green]")
|
|
1215
|
+
else:
|
|
1216
|
+
console.print("[red]❌ Failed to disable privacy[/red]")
|
|
1217
|
+
sys.exit(1)
|
|
1218
|
+
|
|
1219
|
+
except ValueError as e:
|
|
1220
|
+
console.print(f"[red]❌ {e}[/red]")
|
|
1221
|
+
sys.exit(1)
|
|
1222
|
+
except NamecheapError as e:
|
|
1223
|
+
console.print(f"[red]❌ Error: {e}[/red]")
|
|
1224
|
+
sys.exit(1)
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
@privacy_group.command("renew")
|
|
1228
|
+
@click.argument("domain")
|
|
1229
|
+
@click.option("--years", "-y", type=int, default=1, help="Years to renew (1-9)")
|
|
1230
|
+
@click.option("--yes", is_flag=True, help="Skip confirmation")
|
|
1231
|
+
@pass_config
|
|
1232
|
+
def privacy_renew(config: Config, domain: str, years: int, yes: bool) -> None:
|
|
1233
|
+
"""Renew domain privacy subscription.
|
|
1234
|
+
|
|
1235
|
+
Example:
|
|
1236
|
+
namecheap-cli privacy renew example.com --years 2
|
|
1237
|
+
"""
|
|
1238
|
+
nc = config.init_client()
|
|
1239
|
+
|
|
1240
|
+
try:
|
|
1241
|
+
if not yes and not config.quiet:
|
|
1242
|
+
console.print(
|
|
1243
|
+
f"\n[yellow]Renewing WhoisGuard for {domain} ({years} year{'s' if years > 1 else ''}).[/yellow]"
|
|
1244
|
+
)
|
|
1245
|
+
if not Confirm.ask("Continue?", default=True):
|
|
1246
|
+
console.print("[yellow]Cancelled[/yellow]")
|
|
1247
|
+
return
|
|
1248
|
+
|
|
1249
|
+
with Progress(
|
|
1250
|
+
SpinnerColumn(),
|
|
1251
|
+
TextColumn("[progress.description]{task.description}"),
|
|
1252
|
+
transient=True,
|
|
1253
|
+
) as progress:
|
|
1254
|
+
progress.add_task(f"Renewing privacy for {domain}...", total=None)
|
|
1255
|
+
result = nc.whoisguard.renew(domain, years=years)
|
|
1256
|
+
|
|
1257
|
+
if result["is_renewed"]:
|
|
1258
|
+
console.print(f"[green]✅ Privacy renewed for {domain}[/green]")
|
|
1259
|
+
console.print(f"[dim]Charged: {result['charged_amount']}[/dim]")
|
|
1260
|
+
else:
|
|
1261
|
+
console.print("[red]❌ Failed to renew privacy[/red]")
|
|
1262
|
+
sys.exit(1)
|
|
1263
|
+
|
|
1264
|
+
except ValueError as e:
|
|
1265
|
+
console.print(f"[red]❌ {e}[/red]")
|
|
1266
|
+
sys.exit(1)
|
|
1267
|
+
except NamecheapError as e:
|
|
1268
|
+
console.print(f"[red]❌ Error: {e}[/red]")
|
|
1269
|
+
sys.exit(1)
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
@privacy_group.command("change-email")
|
|
1273
|
+
@click.argument("domain")
|
|
1274
|
+
@pass_config
|
|
1275
|
+
def privacy_change_email(config: Config, domain: str) -> None:
|
|
1276
|
+
"""Rotate the privacy forwarding email address.
|
|
1277
|
+
|
|
1278
|
+
Namecheap generates a new masked email automatically.
|
|
1279
|
+
|
|
1280
|
+
Example:
|
|
1281
|
+
namecheap-cli privacy change-email example.com
|
|
1282
|
+
"""
|
|
1283
|
+
nc = config.init_client()
|
|
1284
|
+
|
|
1285
|
+
try:
|
|
1286
|
+
with Progress(
|
|
1287
|
+
SpinnerColumn(),
|
|
1288
|
+
TextColumn("[progress.description]{task.description}"),
|
|
1289
|
+
transient=True,
|
|
1290
|
+
) as progress:
|
|
1291
|
+
progress.add_task(f"Rotating privacy email for {domain}...", total=None)
|
|
1292
|
+
result = nc.whoisguard.change_email(domain)
|
|
1293
|
+
|
|
1294
|
+
console.print(f"[green]✅ Privacy email rotated for {domain}[/green]")
|
|
1295
|
+
console.print(f" New: {result['new_email']}")
|
|
1296
|
+
console.print(f" Old: {result['old_email']}")
|
|
1297
|
+
|
|
1298
|
+
except ValueError as e:
|
|
1299
|
+
console.print(f"[red]❌ {e}[/red]")
|
|
1300
|
+
sys.exit(1)
|
|
1301
|
+
except NamecheapError as e:
|
|
1302
|
+
console.print(f"[red]❌ Error: {e}[/red]")
|
|
1303
|
+
sys.exit(1)
|
|
1304
|
+
|
|
1305
|
+
|
|
1019
1306
|
@cli.group("account")
|
|
1020
1307
|
def account_group() -> None:
|
|
1021
1308
|
"""Account management commands."""
|
|
@@ -1063,6 +1350,87 @@ def account_balance(config: Config) -> None:
|
|
|
1063
1350
|
sys.exit(1)
|
|
1064
1351
|
|
|
1065
1352
|
|
|
1353
|
+
@account_group.command("pricing")
|
|
1354
|
+
@click.argument("tld", required=False)
|
|
1355
|
+
@click.option(
|
|
1356
|
+
"--action",
|
|
1357
|
+
"-a",
|
|
1358
|
+
type=click.Choice(
|
|
1359
|
+
["REGISTER", "RENEW", "TRANSFER", "REACTIVATE"], case_sensitive=False
|
|
1360
|
+
),
|
|
1361
|
+
default="REGISTER",
|
|
1362
|
+
help="Pricing action",
|
|
1363
|
+
)
|
|
1364
|
+
@pass_config
|
|
1365
|
+
def account_pricing(config: Config, tld: str | None, action: str) -> None:
|
|
1366
|
+
"""Get domain pricing. Optionally filter by TLD.
|
|
1367
|
+
|
|
1368
|
+
Examples:
|
|
1369
|
+
namecheap-cli account pricing com
|
|
1370
|
+
namecheap-cli account pricing --action RENEW
|
|
1371
|
+
namecheap-cli account pricing io --action REGISTER
|
|
1372
|
+
"""
|
|
1373
|
+
nc = config.init_client()
|
|
1374
|
+
|
|
1375
|
+
try:
|
|
1376
|
+
with Progress(
|
|
1377
|
+
SpinnerColumn(),
|
|
1378
|
+
TextColumn("[progress.description]{task.description}"),
|
|
1379
|
+
transient=True,
|
|
1380
|
+
) as progress:
|
|
1381
|
+
progress.add_task("Getting pricing...", total=None)
|
|
1382
|
+
pricing = nc.users.get_pricing(
|
|
1383
|
+
"DOMAIN",
|
|
1384
|
+
action=action.upper(),
|
|
1385
|
+
product_name=tld,
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
action_products = pricing.get(action.upper(), {})
|
|
1389
|
+
|
|
1390
|
+
if config.output_format == "table":
|
|
1391
|
+
if not action_products:
|
|
1392
|
+
console.print("[yellow]No pricing data found[/yellow]")
|
|
1393
|
+
return
|
|
1394
|
+
|
|
1395
|
+
table = Table(title=f"Domain Pricing — {action.upper()}")
|
|
1396
|
+
table.add_column("TLD", style="cyan")
|
|
1397
|
+
table.add_column("Duration", justify="center")
|
|
1398
|
+
table.add_column("Price", style="green", justify="right")
|
|
1399
|
+
table.add_column("Regular", style="dim", justify="right")
|
|
1400
|
+
table.add_column("Your Price", style="yellow", justify="right")
|
|
1401
|
+
|
|
1402
|
+
for product_name, prices in sorted(action_products.items()):
|
|
1403
|
+
for p in prices:
|
|
1404
|
+
table.add_row(
|
|
1405
|
+
f".{product_name}",
|
|
1406
|
+
f"{p.duration} {'year' if p.duration == 1 else 'years'}",
|
|
1407
|
+
f"${p.price}" if p.price else "-",
|
|
1408
|
+
f"${p.regular_price}" if p.regular_price else "-",
|
|
1409
|
+
f"${p.your_price}" if p.your_price else "-",
|
|
1410
|
+
)
|
|
1411
|
+
|
|
1412
|
+
console.print(table)
|
|
1413
|
+
else:
|
|
1414
|
+
data = []
|
|
1415
|
+
for product_name, prices in action_products.items():
|
|
1416
|
+
for p in prices:
|
|
1417
|
+
data.append(
|
|
1418
|
+
{
|
|
1419
|
+
"tld": product_name,
|
|
1420
|
+
"duration": p.duration,
|
|
1421
|
+
"price": str(p.price),
|
|
1422
|
+
"regular_price": str(p.regular_price),
|
|
1423
|
+
"your_price": str(p.your_price),
|
|
1424
|
+
"currency": p.currency,
|
|
1425
|
+
}
|
|
1426
|
+
)
|
|
1427
|
+
output_formatter(data, config.output_format)
|
|
1428
|
+
|
|
1429
|
+
except NamecheapError as e:
|
|
1430
|
+
console.print(f"[red]❌ Error: {e}[/red]")
|
|
1431
|
+
sys.exit(1)
|
|
1432
|
+
|
|
1433
|
+
|
|
1066
1434
|
@cli.group("config")
|
|
1067
1435
|
def config_group() -> None:
|
|
1068
1436
|
"""Configuration management commands."""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: namecheap-python
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: A friendly Python SDK for Namecheap API
|
|
5
5
|
Project-URL: Homepage, https://github.com/adriangalilea/namecheap-python
|
|
6
6
|
Project-URL: Repository, https://github.com/adriangalilea/namecheap-python
|
|
@@ -377,6 +377,18 @@ print(f"{bal.available_balance} {bal.currency}") # '4932.96 USD'
|
|
|
377
377
|
print(bal.funds_required_for_auto_renew) # Decimal('20.16')
|
|
378
378
|
```
|
|
379
379
|
|
|
380
|
+
### Pricing
|
|
381
|
+
|
|
382
|
+
```python
|
|
383
|
+
# Get registration pricing for a specific TLD
|
|
384
|
+
pricing = nc.users.get_pricing("DOMAIN", action="REGISTER", product_name="com")
|
|
385
|
+
for p in pricing["REGISTER"]["com"]:
|
|
386
|
+
print(f"{p.duration} year: ${p.your_price} (regular: ${p.regular_price})")
|
|
387
|
+
|
|
388
|
+
# Get all domain pricing (large response — cache it)
|
|
389
|
+
all_pricing = nc.users.get_pricing("DOMAIN")
|
|
390
|
+
```
|
|
391
|
+
|
|
380
392
|
### Email Forwarding
|
|
381
393
|
|
|
382
394
|
```python
|
|
@@ -400,6 +412,41 @@ print(f"{contacts.registrant.first_name} {contacts.registrant.last_name}")
|
|
|
400
412
|
print(contacts.registrant.email)
|
|
401
413
|
```
|
|
402
414
|
|
|
415
|
+
### TLD List
|
|
416
|
+
|
|
417
|
+
```python
|
|
418
|
+
tlds = nc.domains.get_tld_list()
|
|
419
|
+
print(f"{len(tlds)} TLDs supported")
|
|
420
|
+
|
|
421
|
+
# Filter to API-registerable TLDs
|
|
422
|
+
registerable = [t for t in tlds if t.is_api_registerable]
|
|
423
|
+
for t in registerable[:5]:
|
|
424
|
+
print(f".{t.name} ({t.type}) — {t.min_register_years}-{t.max_register_years} years")
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
### Domain Privacy (WhoisGuard)
|
|
428
|
+
|
|
429
|
+
```python
|
|
430
|
+
# List all WhoisGuard subscriptions
|
|
431
|
+
entries = nc.whoisguard.get_list()
|
|
432
|
+
for e in entries:
|
|
433
|
+
print(f"{e.domain} (ID={e.id}) status={e.status}")
|
|
434
|
+
|
|
435
|
+
# Enable privacy (resolves WhoisGuard ID from domain name automatically)
|
|
436
|
+
nc.whoisguard.enable("example.com", "me@gmail.com")
|
|
437
|
+
|
|
438
|
+
# Disable privacy
|
|
439
|
+
nc.whoisguard.disable("example.com")
|
|
440
|
+
|
|
441
|
+
# Renew privacy
|
|
442
|
+
result = nc.whoisguard.renew("example.com", years=1)
|
|
443
|
+
print(f"Charged: {result['charged_amount']}")
|
|
444
|
+
|
|
445
|
+
# Rotate the masked forwarding email
|
|
446
|
+
result = nc.whoisguard.change_email("example.com")
|
|
447
|
+
print(f"New: {result['new_email']}")
|
|
448
|
+
```
|
|
449
|
+
|
|
403
450
|
### Domain Management
|
|
404
451
|
|
|
405
452
|
```python
|
|
@@ -461,6 +508,15 @@ except NamecheapError as e:
|
|
|
461
508
|
|
|
462
509
|
This section documents undocumented or unusual Namecheap API behaviors we've discovered:
|
|
463
510
|
|
|
511
|
+
### No WHOIS lookups or Marketplace data
|
|
512
|
+
|
|
513
|
+
The Namecheap API only operates on domains **in your account**. There is no API for:
|
|
514
|
+
- WHOIS lookups on arbitrary domains
|
|
515
|
+
- Checking if a domain is listed on [Namecheap Marketplace](https://www.namecheap.com/domains/marketplace/)
|
|
516
|
+
- Aftermarket pricing or availability
|
|
517
|
+
|
|
518
|
+
`domains.check()` tells you if a domain is **unregistered**, not if it's for sale by its owner.
|
|
519
|
+
|
|
464
520
|
### TTL "Automatic" = 1799 seconds
|
|
465
521
|
|
|
466
522
|
The Namecheap web interface displays TTL as **"Automatic"** when the value is exactly **1799 seconds**, but shows **"30 min"** when it's **1800 seconds**. This behavior is completely undocumented in their official API documentation.
|
|
@@ -477,15 +533,15 @@ nc.dns.builder().a("www", "192.0.2.1", ttl=1800) # Shows as "30 min"
|
|
|
477
533
|
|
|
478
534
|
| API | Status | Methods |
|
|
479
535
|
|-----|--------|---------|
|
|
480
|
-
| `namecheap.domains.*` | ✅ Done | `check`, `list`, `getInfo`, `getContacts`, `register`, `renew`, `setContacts`, `lock`/`unlock` |
|
|
536
|
+
| `namecheap.domains.*` | ✅ Done | `check`, `list`, `getInfo`, `getContacts`, `getTldList`, `register`, `renew`, `setContacts`, `lock`/`unlock` |
|
|
481
537
|
| `namecheap.domains.dns.*` | ✅ Done | `getHosts`, `setHosts` (builder pattern), `add`, `delete`, `export`, `getList`, `setCustom`, `setDefault`, `getEmailForwarding`, `setEmailForwarding` |
|
|
482
|
-
| `namecheap.
|
|
483
|
-
| `namecheap.
|
|
484
|
-
| `namecheap.users.address.*` | 🚧 Planned |
|
|
485
|
-
| `namecheap.ssl.*` | 🚧 Planned |
|
|
486
|
-
| `namecheap.domains.transfer.*` | 🚧 Planned |
|
|
487
|
-
| `namecheap.domains.ns.*` | 🚧 Planned | Glue records —
|
|
488
|
-
| `namecheap.
|
|
538
|
+
| `namecheap.whoisguard.*` | ✅ Done | `getList`, `enable`, `disable`, `renew`, `changeEmailAddress` |
|
|
539
|
+
| `namecheap.users.*` | ⚠️ Partial | `getBalances`, `getPricing`. Remaining methods are account management (`changePassword`, `update`, `create`, `login`, `resetPassword`) — only useful if building a reseller platform |
|
|
540
|
+
| `namecheap.users.address.*` | 🚧 Planned | Saved address book for `domains.register()` — store contacts once, reuse by ID instead of passing full contact info every time |
|
|
541
|
+
| `namecheap.ssl.*` | 🚧 Planned | Full SSL certificate lifecycle — purchase, activate with CSR, renew, revoke, reissue. Complex multi-step workflows with approval emails |
|
|
542
|
+
| `namecheap.domains.transfer.*` | 🚧 Planned | Transfer domains into Namecheap programmatically — initiate, track status, retry |
|
|
543
|
+
| `namecheap.domains.ns.*` | 🚧 Planned | Glue records — only needed if you run your own nameservers and need to register them with the registry |
|
|
544
|
+
| `namecheap.domains.*` | 🚧 Planned | `reactivate` — restore expired domains within the redemption grace period |
|
|
489
545
|
|
|
490
546
|
## 🛠️ Development
|
|
491
547
|
|
|
@@ -497,7 +553,7 @@ MIT License - see [LICENSE](LICENSE) file for details.
|
|
|
497
553
|
|
|
498
554
|
## 🤝 Contributing
|
|
499
555
|
|
|
500
|
-
Contributions are welcome! Please feel free to submit a Pull Request. See
|
|
556
|
+
Contributions are welcome! Please feel free to submit a Pull Request. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions and guidelines.
|
|
501
557
|
|
|
502
558
|
### Contributors
|
|
503
559
|
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
namecheap/__init__.py,sha256=
|
|
2
|
-
namecheap/client.py,sha256=
|
|
1
|
+
namecheap/__init__.py,sha256=hXo3ATiYSo40CbXPNlY9Q02wrU4Kfr3FDBdRMs5ZCcc,981
|
|
2
|
+
namecheap/client.py,sha256=c-JxBygXjD62bcTDzD9jv8hzd9K-C4TzzSoQQxT7H6I,6897
|
|
3
3
|
namecheap/errors.py,sha256=5bGbV1e4_jkK8YXZXbLF6GJCVUTKw1CtMl9-mz7ogZg,5010
|
|
4
4
|
namecheap/logging.py,sha256=lMR1fr1dWWz3z2NFEY-vl8b52FmmhH76R2NjyifSdYA,3396
|
|
5
|
-
namecheap/models.py,sha256=
|
|
5
|
+
namecheap/models.py,sha256=KMy3tvd3LOeQDhCbipZ7qLZcHJs4aw6b-7Kk-xkie50,18102
|
|
6
6
|
namecheap/_api/__init__.py,sha256=ymQxKCySphoeoo4s_J0tLziXttLNhOQ8AZbCzFcuAHs,36
|
|
7
7
|
namecheap/_api/base.py,sha256=FoczO1Q860PaFUFv-S3IoIV2xaGVJAlchkWnmTI6dlw,6121
|
|
8
8
|
namecheap/_api/dns.py,sha256=Hny5TsVWmmG-3rF6kb8JwboGFt2wKsd4-Z6T8GannBM,17213
|
|
9
|
-
namecheap/_api/domains.py,sha256=
|
|
10
|
-
namecheap/_api/users.py,sha256=
|
|
9
|
+
namecheap/_api/domains.py,sha256=IcPcmiGpJLgqCOjNUCRP3dwt-Kbdc0nAINnEvU72DvU,15121
|
|
10
|
+
namecheap/_api/users.py,sha256=QHTYVQnPvSBgaXUVPR2H-4M20dF45qNYFiS_2z63lxk,3189
|
|
11
|
+
namecheap/_api/whoisguard.py,sha256=R7jBiWlFqxu0EKwtayJEFHZwqub2ELd6pziaiQvxvd8,6207
|
|
11
12
|
namecheap_cli/README.md,sha256=liduIiGr8DHXGTht5swrYnvtAlcdCMQOnSdCD61g4Vw,7337
|
|
12
13
|
namecheap_cli/__init__.py,sha256=nGRHc_CkO4xKhSQdAVG-koEffP8VS0TvbfbZkg7Jg4k,108
|
|
13
|
-
namecheap_cli/__main__.py,sha256=
|
|
14
|
+
namecheap_cli/__main__.py,sha256=yWlZRDDk2a79kiyBZ-7Z49zK978afM0SwWlJO_iVRx8,52551
|
|
14
15
|
namecheap_cli/completion.py,sha256=JTEMnceQli7TombjZkHh-IcZKW4RFRI8Yk5VynxPsEA,2777
|
|
15
16
|
namecheap_dns_tui/README.md,sha256=It16ZiZh0haEeaENfF5HX0Ec4dBawdTYiAi-TiG9wi0,1690
|
|
16
17
|
namecheap_dns_tui/__init__.py,sha256=-yL_1Ha41FlQcmjG-raUrZP9CjTJD3d0w2BW2X-twJg,106
|
|
@@ -19,8 +20,8 @@ namecheap_dns_tui/assets/screenshot1.png,sha256=OXO2P80ll5WRzLYgaakcNnzos8svlJoX
|
|
|
19
20
|
namecheap_dns_tui/assets/screenshot2.png,sha256=5VN_qDMNhWEyrOqKw7vxl1h-TgmZQ_V9aph3Xmf_AFg,279194
|
|
20
21
|
namecheap_dns_tui/assets/screenshot3.png,sha256=h39wSKxx1JCkgeAB7Q3_JlBcAtX1vsRFKtWtOwbBVso,220625
|
|
21
22
|
namecheap_dns_tui/assets/screenshot4.png,sha256=J4nCOW16z3vaRiPbcMiiIRgV7q3XFbi_1N1ivD1Pa4Y,238068
|
|
22
|
-
namecheap_python-1.
|
|
23
|
-
namecheap_python-1.
|
|
24
|
-
namecheap_python-1.
|
|
25
|
-
namecheap_python-1.
|
|
26
|
-
namecheap_python-1.
|
|
23
|
+
namecheap_python-1.5.0.dist-info/METADATA,sha256=LNO8PE7Zuivr5XH5_frOmTTdhD0j3gCg4IgTpEdR9Oc,20807
|
|
24
|
+
namecheap_python-1.5.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
25
|
+
namecheap_python-1.5.0.dist-info/entry_points.txt,sha256=AyhiXroLUpM0Vdo_-RvH0S8o4XDPsDlsEl_65vm6DEk,96
|
|
26
|
+
namecheap_python-1.5.0.dist-info/licenses/LICENSE,sha256=pemTblFP6BBje3bBv_yL_sr2iAqB2H0-LdWMvVIR42o,1062
|
|
27
|
+
namecheap_python-1.5.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|