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.
- npmctl_namecheap-0.3.2/PKG-INFO +28 -0
- npmctl_namecheap-0.3.2/README.md +18 -0
- npmctl_namecheap-0.3.2/pyproject.toml +18 -0
- npmctl_namecheap-0.3.2/src/npmctl_namecheap/__init__.py +5 -0
- npmctl_namecheap-0.3.2/src/npmctl_namecheap/client.py +55 -0
- npmctl_namecheap-0.3.2/src/npmctl_namecheap/config.py +47 -0
- npmctl_namecheap-0.3.2/src/npmctl_namecheap/errors.py +7 -0
- npmctl_namecheap-0.3.2/src/npmctl_namecheap/models.py +60 -0
- npmctl_namecheap-0.3.2/src/npmctl_namecheap/provider.py +27 -0
- npmctl_namecheap-0.3.2/src/npmctl_namecheap/py.typed +1 -0
|
@@ -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,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,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))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|