Skip to content

Commit d5cb292

Browse files
committed
feat: expose users.get_pricing as public SDK method, rewrite _get_pricing
- ProductPrice model for structured pricing data - nc.users.get_pricing() with action/product_name filters - Rewrote DomainsAPI._get_pricing (140→35 lines) using self.client.users - CLI: namecheap-cli account pricing [tld] [--action REGISTER|RENEW|...] - Normalized action names to uppercase, product names to lowercase
1 parent 81ec622 commit d5cb292

File tree

8 files changed

+219
-144
lines changed

8 files changed

+219
-144
lines changed

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,18 @@ print(f"{bal.available_balance} {bal.currency}") # '4932.96 USD'
335335
print(bal.funds_required_for_auto_renew) # Decimal('20.16')
336336
```
337337

338+
### Pricing
339+
340+
```python
341+
# Get registration pricing for a specific TLD
342+
pricing = nc.users.get_pricing("DOMAIN", action="REGISTER", product_name="com")
343+
for p in pricing["REGISTER"]["com"]:
344+
print(f"{p.duration} year: ${p.your_price} (regular: ${p.regular_price})")
345+
346+
# Get all domain pricing (large response — cache it)
347+
all_pricing = nc.users.get_pricing("DOMAIN")
348+
```
349+
338350
### Email Forwarding
339351
340352
```python
@@ -482,7 +494,7 @@ nc.dns.builder().a("www", "192.0.2.1", ttl=1800) # Shows as "30 min"
482494
| `namecheap.domains.*` | ✅ Done | `check`, `list`, `getInfo`, `getContacts`, `getTldList`, `register`, `renew`, `setContacts`, `lock`/`unlock` |
483495
| `namecheap.domains.dns.*` | ✅ Done | `getHosts`, `setHosts` (builder pattern), `add`, `delete`, `export`, `getList`, `setCustom`, `setDefault`, `getEmailForwarding`, `setEmailForwarding` |
484496
| `namecheap.whoisguard.*` | ✅ Done | `getList`, `enable`, `disable`, `renew`, `changeEmailAddress` |
485-
| `namecheap.users.*` | ⚠️ Partial | `getBalances`, `getPricing` (needs debugging). Remaining methods are account management (`changePassword`, `update`, `create`, `login`, `resetPassword`) — only useful if building a reseller platform |
497+
| `namecheap.users.*` | ⚠️ Partial | `getBalances`, `getPricing`. Remaining methods are account management (`changePassword`, `update`, `create`, `login`, `resetPassword`) — only useful if building a reseller platform |
486498
| `namecheap.users.address.*` | 🚧 Planned | Saved address book for `domains.register()` — store contacts once, reuse by ID instead of passing full contact info every time |
487499
| `namecheap.ssl.*` | 🚧 Planned | Full SSL certificate lifecycle — purchase, activate with CSR, renew, revoke, reissue. Complex multi-step workflows with approval emails |
488500
| `namecheap.domains.transfer.*` | 🚧 Planned | Transfer domains into Namecheap programmatically — initiate, track status, retry |

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "namecheap-python"
3-
version = "1.4.0"
3+
version = "1.5.0"
44
description = "A friendly Python SDK for Namecheap API"
55
authors = [{name = "Adrian Galilea Delgado", email = "adriangalilea@gmail.com"}]
66
readme = "README.md"

src/namecheap/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,12 @@
2222
DomainInfo,
2323
EmailForward,
2424
Nameservers,
25+
ProductPrice,
2526
Tld,
2627
WhoisguardEntry,
2728
)
2829

29-
__version__ = "1.4.0"
30+
__version__ = "1.5.0"
3031
__all__ = [
3132
"AccountBalance",
3233
"ConfigurationError",
@@ -40,6 +41,7 @@
4041
"Namecheap",
4142
"NamecheapError",
4243
"Nameservers",
44+
"ProductPrice",
4345
"Tld",
4446
"ValidationError",
4547
"WhoisguardEntry",

src/namecheap/_api/domains.py

Lines changed: 20 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -418,153 +418,35 @@ def unlock(self, domain: str) -> bool:
418418
def _get_pricing(
419419
self, domains: builtins.list[str]
420420
) -> dict[str, dict[str, Decimal | None]]:
421-
"""
422-
Get pricing information for domains.
423-
424-
Args:
425-
domains: List of domain names
421+
"""Get 1-year registration pricing for a list of domains.
426422
427-
Returns:
428-
Dict mapping domain to pricing info
423+
Groups by TLD, fetches pricing via users.get_pricing(), and maps
424+
back to individual domain names.
429425
"""
430-
pricing = {}
431-
logger.debug(f"Getting pricing for domains: {domains}")
426+
pricing: dict[str, dict[str, Decimal | None]] = {}
432427

433-
# Group domains by TLD for efficient API calls
428+
# Group domains by TLD
434429
tld_groups: dict[str, builtins.list[str]] = {}
435430
for domain in domains:
436-
ext = tldextract.extract(domain)
437-
tld = ext.suffix
431+
tld = tldextract.extract(domain).suffix
438432
if tld not in tld_groups:
439433
tld_groups[tld] = []
440434
tld_groups[tld].append(domain)
441435

442-
logger.debug(f"TLD groups: {tld_groups}")
443-
444-
# Fetch pricing for each TLD group
445436
for tld, domain_list in tld_groups.items():
446-
try:
447-
logger.debug(f"Fetching pricing for TLD: {tld}")
448-
# Get pricing for this TLD
449-
result: Any = self._request(
450-
"namecheap.users.getPricing",
451-
{
452-
"ProductType": "DOMAIN",
453-
"ActionName": "REGISTER",
454-
"ProductName": tld,
455-
},
456-
path="UserGetPricingResult.ProductType",
457-
)
458-
assert isinstance(result, dict)
459-
logger.debug(f"Pricing API response for {tld}: {result}")
460-
logger.debug(f"Response type: {type(result)}")
461-
logger.debug(
462-
f"Response keys: "
463-
f"{list(result.keys()) if isinstance(result, dict) else 'Not a dict'}"
464-
)
465-
466-
# Extract pricing info
467-
if isinstance(result, dict):
468-
logger.debug(f"Parsing pricing response for {tld}")
469-
470-
# Get ProductCategory (could be a list or single dict)
471-
categories = result.get("ProductCategory", {})
472-
if not isinstance(categories, list):
473-
categories = [categories] if categories else []
474-
475-
logger.debug(f"Found {len(categories)} categories")
476-
477-
# Look for REGISTER category
478-
for category in categories:
479-
if not isinstance(category, dict):
480-
continue
481-
482-
# Use normalized name for consistent access
483-
category_name = category.get("@Name", "")
484-
category_name_normalized = category.get(
485-
"@Name_normalized", category_name.lower()
486-
)
487-
logger.debug(
488-
f"Checking category: {category_name} "
489-
f"(normalized: {category_name_normalized})"
490-
)
491-
492-
if category_name_normalized == "register":
493-
# Get products in this category
494-
products = category.get("Product", {})
495-
if not isinstance(products, list):
496-
products = [products] if products else []
497-
498-
logger.debug(
499-
f"Found {len(products)} products in REGISTER category"
500-
)
501-
502-
# Find the product matching our TLD
503-
for product in products:
504-
if not isinstance(product, dict):
505-
continue
506-
507-
product_name = product.get("@Name", "")
508-
logger.debug(
509-
f"Checking product: {product_name} vs {tld}"
510-
)
511-
512-
if product_name.lower() == tld.lower():
513-
# Get price list
514-
price_info = product.get("Price", [])
515-
if not isinstance(price_info, list):
516-
price_info = [price_info] if price_info else []
517-
518-
logger.debug(
519-
f"Found {len(price_info)} price entries for {tld}"
520-
)
521-
522-
# Find 1 year price
523-
for price in price_info:
524-
if not isinstance(price, dict):
525-
continue
526-
527-
duration = price.get("@Duration", "")
528-
if duration == "1":
529-
regular_price = price.get("@RegularPrice")
530-
your_price = price.get("@YourPrice")
531-
retail_price = price.get("@RetailPrice")
532-
533-
# Get additional cost
534-
# (normalization handles their typo)
535-
price.get("@YourAdditionalCost", "0")
536-
537-
logger.debug(
538-
f"Found prices for {tld}: "
539-
f"regular={regular_price}, "
540-
f"your={your_price}, "
541-
f"retail={retail_price}"
542-
)
543-
544-
# Apply to all domains with this TLD
545-
for domain in domain_list:
546-
pricing[domain] = {
547-
"regular_price": Decimal(
548-
regular_price
549-
)
550-
if regular_price
551-
else None,
552-
"your_price": Decimal(your_price)
553-
if your_price
554-
else None,
555-
"retail_price": Decimal(
556-
retail_price
557-
)
558-
if retail_price
559-
else None,
560-
}
561-
break
562-
break
563-
break
564-
565-
except Exception as e:
566-
# If pricing fails, continue without it
567-
logger.error(f"Failed to get pricing for TLD {tld}: {e}")
568-
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
569451

570452
return pricing

src/namecheap/_api/users.py

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
from __future__ import annotations
44

5-
from typing import Any
5+
from typing import Any, Literal
66

7-
from namecheap.models import AccountBalance
7+
from namecheap.models import AccountBalance, ProductPrice
88

99
from .base import BaseAPI
1010

@@ -30,3 +30,73 @@ def get_balances(self) -> AccountBalance:
3030

3131
assert result, "API returned empty result for getBalances"
3232
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

src/namecheap/models.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,34 @@ def parse_id(cls, v: Any) -> int:
423423
return int(v) if v else 0
424424

425425

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+
426454
class Config(BaseModel):
427455
"""Client configuration with validation."""
428456

0 commit comments

Comments
 (0)