feat: initial ispconfig-py SDK (sites / dns / mail / databases / clients)

Python 3.10+ SDK wrapping the ISPConfig remote SOAP API so we stop writing
throwaway PHP snippets every time we need to touch a site, zone, or mailbox.

Why no zeep: ISPConfig's /remote/index.php exposes PHP SoapServer in non-WSDL
mode and refuses WSDL generation (?wsdl returns a fault). zeep requires WSDL,
so the stated dependency wouldn't actually work. Instead we hand-roll SOAP
envelopes with the stdlib (urllib + xml.etree). Zero runtime deps.

Structure:
- src/ispconfig/_soap.py        — envelope encode/decode, fault surfacing
- src/ispconfig/client.py       — ISPConfigClient context manager, retry
- src/ispconfig/exceptions.py   — ISPConfigError / Auth / Permission / NotFound / Fault
- src/ispconfig/sites.py        — web_domain get/add/update/delete + enable_php / enable_letsencrypt helpers
- src/ispconfig/dns.py          — zones + A/CNAME/MX/TXT records, dns_a_add type-column workaround
- src/ispconfig/mail.py         — mail domains, users, forwards, create_mailbox helper
- src/ispconfig/databases.py    — convenience facade over sites_database_*
- src/ispconfig/clients.py      — client + sys_groupid lookups
- src/ispconfig/types.py        — TypedDicts for response shapes (no pydantic)

Institutional knowledge baked into docstrings + README footgun list:
- sites_web_domain_update 2nd arg is client_id (not primary_id); admin = 0
- sys_groupid=1 -> pass client_id=0 on update, else ownership churns
- fastcgi_php_version vs server_php_id depending on panel version
- dns_a_add type-column bug (<= 3.2.11) — wrapper issues follow-up update
- dns_zone_get_id wants origin WITHOUT trailing dot on 3.2.11+ (contrary to
  what the older snippets say). Verified live against Rackham 2026-04-22.
- mail_user_get returns a bare map on exactly-one-match filter dicts —
  wrapper normalizes to list
- session timeouts mid-op: client detects + re-auths once (max_retries knob)

Tests:
- tests/test_unit.py   — 12 unit tests against a fake transport
- tests/test_smoke.py  — live read-only smoke test, gated on env vars:
    ISPCONFIG_TEST_URL, ISPCONFIG_TEST_USER, ISPCONFIG_TEST_PASS
  Covers login, web_domain_get(156), dns_zone_get_id, mail_user_get filter.

Tooling:
- mypy strict-ish (disallow untyped defs, warn-return-any, no implicit optional)
- ruff with E/F/W/I/B/UP/N/SLF/RUF lint sets
- pip install -e .[dev] for pytest / mypy / ruff
This commit is contained in:
Sulkta 2026-04-22 13:24:58 -07:00
commit fb6e235f2d
19 changed files with 1797 additions and 0 deletions

130
src/ispconfig/client.py Normal file
View file

@ -0,0 +1,130 @@
"""``ISPConfigClient`` — top-level entry point, session lifecycle, submodule wiring."""
from __future__ import annotations
import logging
from types import TracebackType
from typing import Any
from . import exceptions as _exc
from ._soap import SoapFault, SoapTransport
from .clients import ClientsModule
from .databases import DatabasesModule
from .dns import DnsModule
from .mail import MailModule
from .sites import SitesModule
log = logging.getLogger("ispconfig")
class ISPConfigClient:
"""High-level client for the ISPConfig remote SOAP API.
Use as a context manager to auto-login on enter and auto-logout on exit::
with ISPConfigClient(url, "admin", "password") as c:
site = c.sites.web_domain_get(156)
print(site["domain"])
Session IDs are managed internally and never returned to callers.
If a call fails with a session-expired fault, the client re-authenticates
once and retries (controlled by ``max_retries``).
"""
def __init__(
self,
url: str,
username: str,
password: str,
*,
verify_ssl: bool = True,
timeout: float = 30.0,
max_retries: int = 1,
) -> None:
self._url = url
self._username = username
self._password = password
self._max_retries = max_retries
self._transport = SoapTransport(url, verify_ssl=verify_ssl, timeout=timeout)
self._session_id: str | None = None
self.sites = SitesModule(self)
self.dns = DnsModule(self)
self.mail = MailModule(self)
self.databases = DatabasesModule(self)
self.clients = ClientsModule(self)
# ---- context manager ---------------------------------------------
def __enter__(self) -> ISPConfigClient:
self.login()
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
try:
self.logout()
except Exception as e: # pragma: no cover — cleanup path, never raise out
log.warning("logout on __exit__ failed: %s", e)
# ---- session lifecycle -------------------------------------------
def login(self) -> None:
"""Open a session. Stores the session id internally."""
try:
sid = self._transport.call(
"login",
(("username", self._username), ("password", self._password)),
)
except SoapFault as f:
raise _exc.map_fault(f.faultcode, f.faultstring) from f
if not isinstance(sid, str) or not sid:
raise _exc.AuthError("login returned empty session id")
self._session_id = sid
log.debug("ispconfig login ok (session %s...)", sid[:8])
def logout(self) -> bool:
"""Close the session. Safe to call even if never logged in."""
if self._session_id is None:
return False
sid = self._session_id
self._session_id = None
try:
result = self._transport.call("logout", (("session_id", sid),))
except SoapFault as f:
log.debug("logout fault ignored: %s", f)
return False
return bool(result)
@property
def session_id(self) -> str | None:
"""Read-only accessor — exposed for debugging, not for API calls."""
return self._session_id
# ---- the hot path ------------------------------------------------
def _call(self, method: str, *args: tuple[str, Any]) -> Any:
"""Invoke ``method(session_id, *args)`` with typed error mapping + retry.
This is what the submodules call. It prepends the session id,
translates SOAP faults, and retries once on session expiry.
"""
attempts = 0
while True:
if self._session_id is None:
self.login()
sid_arg = ("session_id", self._session_id or "")
try:
return self._transport.call(method, (sid_arg, *args))
except SoapFault as f:
mapped = _exc.map_fault(f.faultcode, f.faultstring)
if _exc.is_session_expired(mapped) and attempts < self._max_retries:
log.info("ispconfig session expired, re-authenticating")
self._session_id = None
attempts += 1
continue
raise mapped from f