From 914885073c64d773580023288c409bb65899dfa1 Mon Sep 17 00:00:00 2001 From: Cosmin Stejerean Date: Mon, 2 Feb 2026 08:55:01 -0800 Subject: [PATCH 1/5] feat: add nameserver management Add support for setting and querying custom nameservers for domains. Library API: - nc.dns.set_custom_nameservers(domain, nameservers) - switch to custom NS - nc.dns.set_default_nameservers(domain) - reset to Namecheap BasicDNS - nc.dns.get_nameserver_info(domain) - get current NS configuration CLI commands: - namecheap-cli dns set-nameservers ... - namecheap-cli dns reset-nameservers - namecheap-cli dns nameservers --- pending.md | 3 + src/namecheap/_api/dns.py | 127 ++++++++++++++++++++++++++++++++ src/namecheap_cli/__main__.py | 131 ++++++++++++++++++++++++++++++++++ 3 files changed, 261 insertions(+) diff --git a/pending.md b/pending.md index 6b1893b..26fe192 100644 --- a/pending.md +++ b/pending.md @@ -60,6 +60,9 @@ Based on the previous Namecheap Python SDK implementation, here's what's still p - ✅ `set()` - Set DNS records (with builder pattern!) - ✅ `add()` - Add single record - ✅ `delete()` - Delete records +- ✅ `set_custom_nameservers()` - Switch to custom nameservers (e.g., Route 53) +- ✅ `set_default_nameservers()` - Reset to Namecheap BasicDNS +- ✅ `get_nameserver_info()` - Get current nameserver configuration ### Enhanced Features - ✅ Smart IP detection and validation diff --git a/src/namecheap/_api/dns.py b/src/namecheap/_api/dns.py index 62f299a..148a7e9 100644 --- a/src/namecheap/_api/dns.py +++ b/src/namecheap/_api/dns.py @@ -381,3 +381,130 @@ def builder() -> DNSRecordBuilder: >>> nc.dns.set("example.com", builder) """ return DNSRecordBuilder() + + def set_custom_nameservers(self, domain: str, nameservers: list[str]) -> bool: + """ + Set custom nameservers for a domain. + + This switches the domain from Namecheap's default DNS to custom + nameservers (e.g., Route 53, Cloudflare, etc.). + + Args: + domain: Domain name + nameservers: List of nameserver hostnames (e.g., ["ns1.example.com", "ns2.example.com"]) + + Returns: + True if successful + + Examples: + >>> nc.dns.set_custom_nameservers("example.com", [ + ... "ns-123.awsdns-45.com", + ... "ns-456.awsdns-67.net", + ... "ns-789.awsdns-89.org", + ... "ns-012.awsdns-12.co.uk", + ... ]) + """ + if not nameservers: + raise ValueError("At least one nameserver is required") + + # Parse domain + ext = tldextract.extract(domain) + if not ext.domain or not ext.suffix: + raise ValueError(f"Invalid domain name: {domain}") + + result: Any = self._request( + "namecheap.domains.dns.setCustom", + { + "SLD": ext.domain, + "TLD": ext.suffix, + "Nameservers": ",".join(nameservers), + }, + path="DomainDNSSetCustomResult", + ) + + return bool(result and result.get("@Updated") == "true") + + def set_default_nameservers(self, domain: str) -> bool: + """ + Reset domain to use Namecheap's default nameservers. + + This switches the domain back to Namecheap BasicDNS from custom nameservers. + + Args: + domain: Domain name + + Returns: + True if successful + + Examples: + >>> nc.dns.set_default_nameservers("example.com") + """ + # Parse domain + ext = tldextract.extract(domain) + if not ext.domain or not ext.suffix: + raise ValueError(f"Invalid domain name: {domain}") + + result: Any = self._request( + "namecheap.domains.dns.setDefault", + { + "SLD": ext.domain, + "TLD": ext.suffix, + }, + path="DomainDNSSetDefaultResult", + ) + + return bool(result and result.get("@Updated") == "true") + + def get_nameserver_info(self, domain: str) -> dict[str, Any]: + """ + Get nameserver information for a domain. + + Returns information about whether the domain is using Namecheap's + default DNS or custom nameservers. + + Args: + domain: Domain name + + Returns: + Dict with nameserver info including: + - using_default: Whether using Namecheap's default DNS + - nameservers: List of current nameservers (if custom) + + Examples: + >>> info = nc.dns.get_nameserver_info("example.com") + >>> if info["using_default"]: + ... print("Using Namecheap DNS") + ... else: + ... print(f"Custom NS: {info['nameservers']}") + """ + # Parse domain + ext = tldextract.extract(domain) + if not ext.domain or not ext.suffix: + raise ValueError(f"Invalid domain name: {domain}") + + result: Any = self._request( + "namecheap.domains.dns.getList", + { + "SLD": ext.domain, + "TLD": ext.suffix, + }, + path="DomainDNSGetListResult", + ) + + if not result: + return {"using_default": True, "nameservers": []} + + using_default = result.get("@IsUsingOurDNS", "false").lower() == "true" + + # Extract nameservers + nameservers = [] + ns_data = result.get("Nameserver", []) + if isinstance(ns_data, str): + nameservers = [ns_data] + elif isinstance(ns_data, list): + nameservers = ns_data + + return { + "using_default": using_default, + "nameservers": nameservers, + } diff --git a/src/namecheap_cli/__main__.py b/src/namecheap_cli/__main__.py index c9c8165..dbcbf83 100644 --- a/src/namecheap_cli/__main__.py +++ b/src/namecheap_cli/__main__.py @@ -700,6 +700,137 @@ def dns_delete( sys.exit(1) +@dns_group.command("nameservers") +@click.argument("domain") +@pass_config +def dns_nameservers(config: Config, domain: str) -> None: + """Show current nameserver configuration for a domain.""" + nc = config.init_client() + + try: + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + transient=True, + ) as progress: + progress.add_task(f"Getting nameserver info for {domain}...", total=None) + info = nc.dns.get_nameserver_info(domain) + + if config.output_format == "table": + console.print(f"\n[bold cyan]Nameservers for {domain}[/bold cyan]\n") + + if info["using_default"]: + console.print("[green]Using Namecheap BasicDNS[/green]") + else: + console.print("[yellow]Using custom nameservers:[/yellow]") + for ns in info["nameservers"]: + console.print(f" • {ns}") + else: + output_formatter(info, config.output_format) + + except NamecheapError as e: + console.print(f"[red]❌ Error: {e}[/red]") + sys.exit(1) + + +@dns_group.command("set-nameservers") +@click.argument("domain") +@click.argument("nameservers", nargs=-1, required=True) +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") +@pass_config +def dns_set_nameservers( + config: Config, domain: str, nameservers: tuple[str, ...], yes: bool +) -> None: + """Set custom nameservers for a domain. + + This switches the domain from Namecheap's default DNS to custom nameservers. + + Example: + namecheap-cli dns set-nameservers example.com ns1.route53.com ns2.route53.com + """ + nc = config.init_client() + + if not nameservers: + console.print("[red]❌ At least one nameserver is required[/red]") + sys.exit(1) + + try: + if not yes and not config.quiet: + console.print(f"\n[yellow]Setting custom nameservers for {domain}:[/yellow]") + for ns in nameservers: + console.print(f" • {ns}") + console.print() + + if not Confirm.ask("Continue?", default=True): + console.print("[yellow]Cancelled[/yellow]") + return + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + transient=True, + ) as progress: + progress.add_task(f"Setting nameservers for {domain}...", total=None) + success = nc.dns.set_custom_nameservers(domain, list(nameservers)) + + if success: + console.print(f"[green]✅ Custom nameservers set for {domain}[/green]") + if not config.quiet: + console.print( + "\n[dim]Note: DNS propagation may take up to 48 hours.[/dim]" + ) + else: + console.print("[red]❌ Failed to set nameservers[/red]") + sys.exit(1) + + except NamecheapError as e: + console.print(f"[red]❌ Error: {e}[/red]") + sys.exit(1) + + +@dns_group.command("reset-nameservers") +@click.argument("domain") +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") +@pass_config +def dns_reset_nameservers(config: Config, domain: str, yes: bool) -> None: + """Reset domain to use Namecheap's default nameservers. + + This switches the domain back to Namecheap BasicDNS from custom nameservers. + """ + nc = config.init_client() + + try: + if not yes and not config.quiet: + console.print( + f"\n[yellow]This will reset {domain} to Namecheap's default DNS.[/yellow]" + ) + if not Confirm.ask("Continue?", default=True): + console.print("[yellow]Cancelled[/yellow]") + return + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + transient=True, + ) as progress: + progress.add_task( + f"Resetting nameservers for {domain}...", total=None + ) + success = nc.dns.set_default_nameservers(domain) + + if success: + console.print( + f"[green]✅ {domain} is now using Namecheap BasicDNS[/green]" + ) + else: + console.print("[red]❌ Failed to reset nameservers[/red]") + sys.exit(1) + + except NamecheapError as e: + console.print(f"[red]❌ Error: {e}[/red]") + sys.exit(1) + + @dns_group.command("export") @click.argument("domain") @click.option( From 3a43eb556e2e1acc7f6b2cbd241d24371eb0994a Mon Sep 17 00:00:00 2001 From: Cosmin Stejerean Date: Mon, 2 Feb 2026 23:17:54 -0800 Subject: [PATCH 2/5] limit to max 5 nameservers Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/namecheap/_api/dns.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/namecheap/_api/dns.py b/src/namecheap/_api/dns.py index 148a7e9..5458f5a 100644 --- a/src/namecheap/_api/dns.py +++ b/src/namecheap/_api/dns.py @@ -406,6 +406,8 @@ def set_custom_nameservers(self, domain: str, nameservers: list[str]) -> bool: """ if not nameservers: raise ValueError("At least one nameserver is required") + if len(nameservers) > 5: + raise ValueError("Maximum of 5 nameservers allowed") # Parse domain ext = tldextract.extract(domain) From a36f8eb9d12e7bce8294e54f5109965f46aeeeca Mon Sep 17 00:00:00 2001 From: Cosmin Stejerean Date: Mon, 2 Feb 2026 23:18:12 -0800 Subject: [PATCH 3/5] remove nameserver required validation in CLI Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/namecheap_cli/__main__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/namecheap_cli/__main__.py b/src/namecheap_cli/__main__.py index dbcbf83..0b37e5b 100644 --- a/src/namecheap_cli/__main__.py +++ b/src/namecheap_cli/__main__.py @@ -750,10 +750,6 @@ def dns_set_nameservers( """ nc = config.init_client() - if not nameservers: - console.print("[red]❌ At least one nameserver is required[/red]") - sys.exit(1) - try: if not yes and not config.quiet: console.print(f"\n[yellow]Setting custom nameservers for {domain}:[/yellow]") From fb7472cc3db37e0d0c01e1c9c877776901150d45 Mon Sep 17 00:00:00 2001 From: Adrian Galilea Date: Tue, 3 Feb 2026 12:02:27 +0100 Subject: [PATCH 4/5] refactor: clean up nameserver API and bump to 1.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - get_nameserver_info() → get_nameservers() returning Nameservers model - Replace defensive code with asserts - Add Nameservers Pydantic model (is_default, nameservers) - Add nameserver management docs to README - Clarify Domain NS API (glue records) vs nameserver switching in pending.md - Sync version to 1.1.0 across pyproject.toml and __init__.py --- README.md | 20 ++++++++++++- pending.md | 12 ++++---- pyproject.toml | 2 +- src/namecheap/__init__.py | 5 ++-- src/namecheap/_api/dns.py | 54 ++++++++++++----------------------- src/namecheap/models.py | 7 +++++ src/namecheap_cli/__main__.py | 22 +++++++------- uv.lock | 2 +- 8 files changed, 66 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 01c7905..401e44f 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,24 @@ nc.dns.set("example.com", **Note on TTL:** The default TTL is **1799 seconds**, which displays as **"Automatic"** in the Namecheap web interface. This is an undocumented Namecheap API behavior. You can specify custom TTL values (60-86400 seconds) in any DNS method. +### Nameserver Management + +```python +# Check current nameservers +ns = nc.dns.get_nameservers("example.com") +print(ns.nameservers) # ['dns1.registrar-servers.com', 'dns2.registrar-servers.com'] +print(ns.is_default) # True + +# Switch to custom nameservers (e.g., Cloudflare, Route 53) +nc.dns.set_custom_nameservers("example.com", [ + "ns1.cloudflare.com", + "ns2.cloudflare.com", +]) + +# Reset back to Namecheap BasicDNS +nc.dns.set_default_nameservers("example.com") +``` + ### Domain Management ```python @@ -339,7 +357,7 @@ The following Namecheap API features are planned for future releases: - **SSL API** - Certificate management - **Domain Transfer API** - Transfer domains between registrars -- **Domain NS API** - Custom nameserver management +- **Domain NS API** - Glue record management (child nameservers) - **Users API** - Account management and balance checking - **Whois API** - WHOIS information lookups - **Email Forwarding** - Email forwarding configuration diff --git a/pending.md b/pending.md index 26fe192..5d585f0 100644 --- a/pending.md +++ b/pending.md @@ -32,11 +32,11 @@ Based on the previous Namecheap Python SDK implementation, here's what's still p - `updateStatus()` - Approve/reject transfer - `getList()` - List pending transfers -### 4. **Domains NS API** (`namecheap.domains.ns.*`) -- `create()` - Create nameserver -- `delete()` - Delete nameserver -- `getInfo()` - Get nameserver details -- `update()` - Update nameserver IP +### 4. **Domains NS API** (`namecheap.domains.ns.*`) — Glue Records +- `create()` - Register a child nameserver (e.g., ns1.yourdomain.com → 1.2.3.4) +- `delete()` - Delete a child nameserver +- `getInfo()` - Get child nameserver details +- `update()` - Update child nameserver IP ### 5. **Whois API** (`namecheap.whois.*`) - `getWhoisInfo()` - Get WHOIS information @@ -62,7 +62,7 @@ Based on the previous Namecheap Python SDK implementation, here's what's still p - ✅ `delete()` - Delete records - ✅ `set_custom_nameservers()` - Switch to custom nameservers (e.g., Route 53) - ✅ `set_default_nameservers()` - Reset to Namecheap BasicDNS -- ✅ `get_nameserver_info()` - Get current nameserver configuration +- ✅ `get_nameservers()` - Get current nameserver configuration ### Enhanced Features - ✅ Smart IP detection and validation diff --git a/pyproject.toml b/pyproject.toml index a8480b0..1c399be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "namecheap-python" -version = "1.0.6" +version = "1.1.0" description = "A friendly Python SDK for Namecheap API" authors = [{name = "Adrian Galilea Delgado", email = "adriangalilea@gmail.com"}] readme = "README.md" diff --git a/src/namecheap/__init__.py b/src/namecheap/__init__.py index 6ab05eb..a867269 100644 --- a/src/namecheap/__init__.py +++ b/src/namecheap/__init__.py @@ -12,9 +12,9 @@ from .client import Namecheap from .errors import ConfigurationError, NamecheapError, ValidationError -from .models import Contact, DNSRecord, Domain, DomainCheck +from .models import Contact, DNSRecord, Domain, DomainCheck, Nameservers -__version__ = "1.0.5" +__version__ = "1.1.0" __all__ = [ "ConfigurationError", "Contact", @@ -23,5 +23,6 @@ "DomainCheck", "Namecheap", "NamecheapError", + "Nameservers", "ValidationError", ] diff --git a/src/namecheap/_api/dns.py b/src/namecheap/_api/dns.py index 5458f5a..25bcdfb 100644 --- a/src/namecheap/_api/dns.py +++ b/src/namecheap/_api/dns.py @@ -6,7 +6,7 @@ import tldextract -from namecheap.models import DNSRecord +from namecheap.models import DNSRecord, Nameservers from .base import BaseAPI @@ -404,12 +404,9 @@ def set_custom_nameservers(self, domain: str, nameservers: list[str]) -> bool: ... "ns-012.awsdns-12.co.uk", ... ]) """ - if not nameservers: - raise ValueError("At least one nameserver is required") - if len(nameservers) > 5: - raise ValueError("Maximum of 5 nameservers allowed") + assert nameservers, "At least one nameserver is required" + assert len(nameservers) <= 5, "Maximum of 5 nameservers allowed" - # Parse domain ext = tldextract.extract(domain) if not ext.domain or not ext.suffix: raise ValueError(f"Invalid domain name: {domain}") @@ -441,7 +438,6 @@ def set_default_nameservers(self, domain: str) -> bool: Examples: >>> nc.dns.set_default_nameservers("example.com") """ - # Parse domain ext = tldextract.extract(domain) if not ext.domain or not ext.suffix: raise ValueError(f"Invalid domain name: {domain}") @@ -457,29 +453,23 @@ def set_default_nameservers(self, domain: str) -> bool: return bool(result and result.get("@Updated") == "true") - def get_nameserver_info(self, domain: str) -> dict[str, Any]: + def get_nameservers(self, domain: str) -> Nameservers: """ - Get nameserver information for a domain. - - Returns information about whether the domain is using Namecheap's - default DNS or custom nameservers. + Get current nameservers for a domain. Args: domain: Domain name Returns: - Dict with nameserver info including: - - using_default: Whether using Namecheap's default DNS - - nameservers: List of current nameservers (if custom) + Nameservers with is_default flag and nameserver hostnames Examples: - >>> info = nc.dns.get_nameserver_info("example.com") - >>> if info["using_default"]: - ... print("Using Namecheap DNS") - ... else: - ... print(f"Custom NS: {info['nameservers']}") + >>> ns = nc.dns.get_nameservers("example.com") + >>> ns.is_default + True + >>> ns.nameservers + ['dns1.registrar-servers.com', 'dns2.registrar-servers.com'] """ - # Parse domain ext = tldextract.extract(domain) if not ext.domain or not ext.suffix: raise ValueError(f"Invalid domain name: {domain}") @@ -493,20 +483,14 @@ def get_nameserver_info(self, domain: str) -> dict[str, Any]: path="DomainDNSGetListResult", ) - if not result: - return {"using_default": True, "nameservers": []} + assert result, f"API returned empty result for {domain} nameserver query" - using_default = result.get("@IsUsingOurDNS", "false").lower() == "true" + is_default = result.get("@IsUsingOurDNS", "false").lower() == "true" - # Extract nameservers - nameservers = [] ns_data = result.get("Nameserver", []) - if isinstance(ns_data, str): - nameservers = [ns_data] - elif isinstance(ns_data, list): - nameservers = ns_data - - return { - "using_default": using_default, - "nameservers": nameservers, - } + assert isinstance(ns_data, str | list), ( + f"Unexpected Nameserver type: {type(ns_data)}" + ) + nameservers = [ns_data] if isinstance(ns_data, str) else ns_data + + return Nameservers(is_default=is_default, nameservers=nameservers) diff --git a/src/namecheap/models.py b/src/namecheap/models.py index 804ce41..d1f2168 100644 --- a/src/namecheap/models.py +++ b/src/namecheap/models.py @@ -251,6 +251,13 @@ def parse_datetime(cls, v: Any) -> datetime: raise ValueError(f"Cannot parse datetime from {v}") +class Nameservers(BaseModel): + """Current nameserver configuration for a domain.""" + + is_default: bool = Field(description="True when using Namecheap's own DNS") + nameservers: list[str] = Field(description="Nameserver hostnames") + + class Contact(BaseModel): """Contact information for domain registration.""" diff --git a/src/namecheap_cli/__main__.py b/src/namecheap_cli/__main__.py index 0b37e5b..e40a472 100644 --- a/src/namecheap_cli/__main__.py +++ b/src/namecheap_cli/__main__.py @@ -714,19 +714,19 @@ def dns_nameservers(config: Config, domain: str) -> None: transient=True, ) as progress: progress.add_task(f"Getting nameserver info for {domain}...", total=None) - info = nc.dns.get_nameserver_info(domain) + ns = nc.dns.get_nameservers(domain) if config.output_format == "table": console.print(f"\n[bold cyan]Nameservers for {domain}[/bold cyan]\n") - if info["using_default"]: + if ns.is_default: console.print("[green]Using Namecheap BasicDNS[/green]") else: console.print("[yellow]Using custom nameservers:[/yellow]") - for ns in info["nameservers"]: - console.print(f" • {ns}") + for nameserver in ns.nameservers: + console.print(f" • {nameserver}") else: - output_formatter(info, config.output_format) + output_formatter(ns.model_dump(), config.output_format) except NamecheapError as e: console.print(f"[red]❌ Error: {e}[/red]") @@ -752,7 +752,9 @@ def dns_set_nameservers( try: if not yes and not config.quiet: - console.print(f"\n[yellow]Setting custom nameservers for {domain}:[/yellow]") + console.print( + f"\n[yellow]Setting custom nameservers for {domain}:[/yellow]" + ) for ns in nameservers: console.print(f" • {ns}") console.print() @@ -809,15 +811,11 @@ def dns_reset_nameservers(config: Config, domain: str, yes: bool) -> None: TextColumn("[progress.description]{task.description}"), transient=True, ) as progress: - progress.add_task( - f"Resetting nameservers for {domain}...", total=None - ) + progress.add_task(f"Resetting nameservers for {domain}...", total=None) success = nc.dns.set_default_nameservers(domain) if success: - console.print( - f"[green]✅ {domain} is now using Namecheap BasicDNS[/green]" - ) + console.print(f"[green]✅ {domain} is now using Namecheap BasicDNS[/green]") else: console.print("[red]❌ Failed to reset nameservers[/red]") sys.exit(1) diff --git a/uv.lock b/uv.lock index 0a84313..893dde2 100644 --- a/uv.lock +++ b/uv.lock @@ -200,7 +200,7 @@ wheels = [ [[package]] name = "namecheap-python" -version = "1.0.5" +version = "1.1.0" source = { editable = "." } dependencies = [ { name = "httpx" }, From 8d9ee3a1cf4890855f74825e837e1e7045e4d3fe Mon Sep 17 00:00:00 2001 From: Adrian Galilea Date: Tue, 3 Feb 2026 12:05:58 +0100 Subject: [PATCH 5/5] docs: credit @cosmin for nameserver management --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 401e44f..1c92ce8 100644 --- a/README.md +++ b/README.md @@ -375,3 +375,7 @@ MIT License - see [LICENSE](LICENSE) file for details. ## 🤝 Contributing Contributions are welcome! Please feel free to submit a Pull Request. See the [Development Guide](docs/dev/README.md) for setup instructions and guidelines. + +### Contributors + +- [@cosmin](https://github.com/cosmin) — Nameserver management