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
48 lines
1.8 KiB
Python
48 lines
1.8 KiB
Python
"""``clients.*`` — ISPConfig client/customer lookups.
|
|
|
|
ISPConfig's client model has two IDs you'll trip over:
|
|
|
|
* ``client_id`` — primary key of the ``client`` table.
|
|
* ``sys_groupid`` — the "owner group" used by sites/dns/mail records.
|
|
``sys_groupid=1`` is the admin group. For regular clients, ``sys_groupid``
|
|
is *not* equal to ``client_id``; you must look it up with
|
|
:meth:`ClientsModule.get_groupid`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, cast
|
|
|
|
from .types import Client
|
|
|
|
if TYPE_CHECKING:
|
|
from .client import ISPConfigClient
|
|
|
|
|
|
class ClientsModule:
|
|
def __init__(self, client: ISPConfigClient) -> None:
|
|
self._c = client
|
|
|
|
def get(self, primary_id: int) -> Client:
|
|
return cast(Client, self._c._call("client_get", ("primary_id", int(primary_id))))
|
|
|
|
def get_groupid(self, client_id: int) -> int:
|
|
"""Look up ``sys_groupid`` for a given ``client_id``."""
|
|
result = self._c._call("client_get_groupid", ("client_id", int(client_id)))
|
|
return int(result) if result else 0
|
|
|
|
def get_id(self, sys_userid: int) -> int:
|
|
"""Reverse of :meth:`get_groupid` — client_id from a sys user id."""
|
|
result = self._c._call("client_get_id", ("sys_userid", int(sys_userid)))
|
|
return int(result) if result else 0
|
|
|
|
def get_by_username(self, username: str) -> Client:
|
|
return cast(Client, self._c._call("client_get_by_username", ("username", username)))
|
|
|
|
def get_by_groupid(self, groupid: int) -> Client:
|
|
return cast(Client, self._c._call("client_get_by_groupid", ("groupid", int(groupid))))
|
|
|
|
def get_all(self) -> list[int]:
|
|
"""Return every ``client_id`` the API user can see."""
|
|
result = self._c._call("client_get_all")
|
|
return [int(x) for x in (result or [])]
|