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:
commit
fb6e235f2d
19 changed files with 1797 additions and 0 deletions
130
src/ispconfig/client.py
Normal file
130
src/ispconfig/client.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue