npmctl-namecheap 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: npmctl-namecheap
3
+ Version: 0.3.2
4
+ Summary: Namecheap DNS provider extension for npmctl.
5
+ Author: npmctl contributors
6
+ License-Expression: Apache-2.0
7
+ Requires-Dist: requests>=2.32.0
8
+ Requires-Python: >=3.10, <3.14
9
+ Description-Content-Type: text/markdown
10
+
11
+ # npmctl-namecheap
12
+
13
+ Namecheap DNS provider extension for `npmctl`.
14
+
15
+ Install the package beside `npmctl`, then inspect discovery with:
16
+
17
+ ```bash
18
+ npmctl plugins list
19
+ npmctl dns doctor --provider namecheap
20
+ ```
21
+
22
+ Configuration is read from environment variables:
23
+
24
+ - `NAMECHEAP_API_USER`
25
+ - `NAMECHEAP_API_KEY`
26
+ - `NAMECHEAP_USERNAME`
27
+ - `NAMECHEAP_CLIENT_IP`
28
+ - `NAMECHEAP_API_BASE_URL` for tests or non-default endpoints
@@ -0,0 +1,18 @@
1
+ # npmctl-namecheap
2
+
3
+ Namecheap DNS provider extension for `npmctl`.
4
+
5
+ Install the package beside `npmctl`, then inspect discovery with:
6
+
7
+ ```bash
8
+ npmctl plugins list
9
+ npmctl dns doctor --provider namecheap
10
+ ```
11
+
12
+ Configuration is read from environment variables:
13
+
14
+ - `NAMECHEAP_API_USER`
15
+ - `NAMECHEAP_API_KEY`
16
+ - `NAMECHEAP_USERNAME`
17
+ - `NAMECHEAP_CLIENT_IP`
18
+ - `NAMECHEAP_API_BASE_URL` for tests or non-default endpoints
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "npmctl-namecheap"
3
+ version = "0.3.2"
4
+ description = "Namecheap DNS provider extension for npmctl."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10,<3.14"
7
+ license = "Apache-2.0"
8
+ authors = [{ name = "npmctl contributors" }]
9
+ dependencies = [
10
+ "requests>=2.32.0",
11
+ ]
12
+
13
+ [project.entry-points."npmctl.dns_providers"]
14
+ namecheap = "npmctl_namecheap.provider:NamecheapDnsProvider"
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.11.8,<0.12"]
18
+ build-backend = "uv_build"
@@ -0,0 +1,5 @@
1
+ """Namecheap DNS extension for npmctl."""
2
+
3
+ from npmctl_namecheap.provider import NamecheapDnsProvider
4
+
5
+ __all__ = ["NamecheapDnsProvider"]
@@ -0,0 +1,55 @@
1
+ """Small Namecheap XML API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from xml.etree import ElementTree
7
+
8
+ import requests
9
+
10
+ from npmctl_namecheap.config import NamecheapConfig
11
+ from npmctl_namecheap.errors import NamecheapError
12
+ from npmctl_namecheap.models import NamecheapRecord, split_zone
13
+
14
+ _NAMESPACE = {"nc": "http://api.namecheap.com/xml.response"}
15
+
16
+
17
+ class NamecheapClient:
18
+ """HTTP client for the Namecheap XML API."""
19
+
20
+ def __init__(self, config: NamecheapConfig, *, timeout_s: float = 15.0) -> None:
21
+ self.config = config
22
+ self.timeout_s = timeout_s
23
+ self.session = requests.Session()
24
+
25
+ def zones(self) -> tuple[str, ...]:
26
+ data = self._request("namecheap.domains.getList")
27
+ domains = data.findall(".//nc:Domain", _NAMESPACE)
28
+ return tuple(sorted(str(item.attrib["Name"]).lower() for item in domains if item.attrib.get("Name")))
29
+
30
+ def records(self, zone: str) -> tuple[NamecheapRecord, ...]:
31
+ sld, tld = split_zone(zone)
32
+ data = self._request("namecheap.domains.dns.getHosts", SLD=sld, TLD=tld)
33
+ hosts = data.findall(".//nc:host", _NAMESPACE)
34
+ return tuple(NamecheapRecord.from_attrs(host.attrib) for host in hosts)
35
+
36
+ def _request(self, command: str, **params: str) -> ElementTree.Element:
37
+ query: dict[str, Any] = {
38
+ "ApiUser": self.config.api_user,
39
+ "ApiKey": self.config.api_key,
40
+ "UserName": self.config.username,
41
+ "ClientIp": self.config.client_ip,
42
+ "Command": command,
43
+ **params,
44
+ }
45
+ response = self.session.get(self.config.api_base_url, params=query, timeout=self.timeout_s)
46
+ if response.status_code < 200 or response.status_code >= 300:
47
+ raise NamecheapError(f"Namecheap API failed: HTTP {response.status_code}")
48
+ try:
49
+ parsed = ElementTree.fromstring(response.text)
50
+ except ElementTree.ParseError as exc:
51
+ raise NamecheapError("Namecheap API returned invalid XML") from exc
52
+ if parsed.attrib.get("Status") == "ERROR":
53
+ errors = [item.text or "unknown error" for item in parsed.findall(".//nc:Error", _NAMESPACE)]
54
+ raise NamecheapError("; ".join(errors))
55
+ return parsed
@@ -0,0 +1,47 @@
1
+ """Configuration loading for the Namecheap DNS provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from typing import Mapping
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class NamecheapConfig:
12
+ """Namecheap API configuration."""
13
+
14
+ api_user: str
15
+ api_key: str
16
+ username: str
17
+ client_ip: str
18
+ api_base_url: str = "https://api.namecheap.com/xml.response"
19
+
20
+ @classmethod
21
+ def from_env(cls, env: Mapping[str, str] | None = None) -> NamecheapConfig:
22
+ values = os.environ if env is None else env
23
+ required = {
24
+ "api_user": values.get("NAMECHEAP_API_USER"),
25
+ "api_key": values.get("NAMECHEAP_API_KEY"),
26
+ "username": values.get("NAMECHEAP_USERNAME"),
27
+ "client_ip": values.get("NAMECHEAP_CLIENT_IP"),
28
+ }
29
+ missing = [name for name, value in required.items() if not value]
30
+ if missing:
31
+ raise ValueError(f"missing Namecheap config: {', '.join(missing)}")
32
+ return cls(
33
+ api_user=str(required["api_user"]),
34
+ api_key=str(required["api_key"]),
35
+ username=str(required["username"]),
36
+ client_ip=str(required["client_ip"]),
37
+ api_base_url=values.get("NAMECHEAP_API_BASE_URL", "https://api.namecheap.com/xml.response"),
38
+ )
39
+
40
+ def redacted(self) -> dict[str, str | bool]:
41
+ return {
42
+ "api_user": bool(self.api_user),
43
+ "api_key": bool(self.api_key),
44
+ "username": bool(self.username),
45
+ "client_ip": bool(self.client_ip),
46
+ "api_base_url": self.api_base_url,
47
+ }
@@ -0,0 +1,7 @@
1
+ """Namecheap provider errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class NamecheapError(RuntimeError):
7
+ """Raised when the Namecheap API returns an error."""
@@ -0,0 +1,60 @@
1
+ """Namecheap DNS response models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Mapping
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class NamecheapRecord:
11
+ """One Namecheap host record."""
12
+
13
+ host_id: str | None
14
+ name: str
15
+ type: str
16
+ address: str
17
+ ttl: int | None = None
18
+ mx_pref: int | None = None
19
+
20
+ @classmethod
21
+ def from_attrs(cls, attrs: Mapping[str, Any]) -> NamecheapRecord:
22
+ ttl = _optional_int(attrs.get("TTL"))
23
+ mx_pref = _optional_int(attrs.get("MXPref"))
24
+ return cls(
25
+ host_id=_optional_str(attrs.get("HostId")),
26
+ name=str(attrs.get("Name", "")).lower(),
27
+ type=str(attrs.get("Type", "")).upper(),
28
+ address=str(attrs.get("Address", "")),
29
+ ttl=ttl,
30
+ mx_pref=mx_pref,
31
+ )
32
+
33
+ def to_dict(self) -> dict[str, str | int | None]:
34
+ return {
35
+ "id": self.host_id,
36
+ "name": self.name,
37
+ "type": self.type,
38
+ "value": self.address,
39
+ "ttl": self.ttl,
40
+ "priority": self.mx_pref,
41
+ }
42
+
43
+
44
+ def split_zone(zone: str) -> tuple[str, str]:
45
+ labels = zone.strip().lower().rstrip(".").split(".")
46
+ if len(labels) < 2 or any(not label for label in labels):
47
+ raise ValueError("zone must be a domain such as example.com")
48
+ return ".".join(labels[:-1]), labels[-1]
49
+
50
+
51
+ def _optional_int(value: Any) -> int | None:
52
+ if value in (None, ""):
53
+ return None
54
+ return int(value)
55
+
56
+
57
+ def _optional_str(value: Any) -> str | None:
58
+ if value in (None, ""):
59
+ return None
60
+ return str(value)
@@ -0,0 +1,27 @@
1
+ """npmctl DNS provider implementation for Namecheap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from npmctl_namecheap.client import NamecheapClient
6
+ from npmctl_namecheap.config import NamecheapConfig
7
+
8
+
9
+ class NamecheapDnsProvider:
10
+ """DNS provider backed by the Namecheap XML API."""
11
+
12
+ name = "namecheap"
13
+
14
+ def __init__(self, client: NamecheapClient | None = None) -> None:
15
+ self._client = client
16
+
17
+ @property
18
+ def client(self) -> NamecheapClient:
19
+ if self._client is None:
20
+ self._client = NamecheapClient(NamecheapConfig.from_env())
21
+ return self._client
22
+
23
+ def zones(self) -> tuple[str, ...]:
24
+ return self.client.zones()
25
+
26
+ def records(self, zone: str) -> tuple[dict[str, object], ...]:
27
+ return tuple(record.to_dict() for record in self.client.records(zone))