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
47
tests/test_smoke.py
Normal file
47
tests/test_smoke.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Live read-only smoke test against a real ISPConfig panel.
|
||||
|
||||
Gated on env vars: ``ISPCONFIG_TEST_URL``, ``ISPCONFIG_TEST_USER``,
|
||||
``ISPCONFIG_TEST_PASS``. These tests are skipped if any is unset.
|
||||
|
||||
They are **read-only** — no ``_add`` / ``_update`` / ``_delete`` calls. Safe
|
||||
to run against production.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from ispconfig import ISPConfigClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(live_creds: dict[str, str]) -> ISPConfigClient:
|
||||
with ISPConfigClient(live_creds["url"], live_creds["user"], live_creds["password"]) as c:
|
||||
yield c # type: ignore[misc]
|
||||
|
||||
|
||||
def test_login_returns_session(live_creds: dict[str, str]) -> None:
|
||||
c = ISPConfigClient(live_creds["url"], live_creds["user"], live_creds["password"])
|
||||
c.login()
|
||||
assert c.session_id and len(c.session_id) > 10
|
||||
assert c.logout() is True
|
||||
|
||||
|
||||
def test_get_domain_156(client: ISPConfigClient) -> None:
|
||||
site = client.sites.web_domain_get(156)
|
||||
assert site["domain"] == "mcbindustrial.com"
|
||||
assert site["php"] == "fast-cgi"
|
||||
|
||||
|
||||
def test_dns_zone_lookup(client: ISPConfigClient) -> None:
|
||||
zone_id = client.dns.zone_get_id("mcbindustrial.com.")
|
||||
assert zone_id > 0, "zone_get_id returned 0 — is the zone present?"
|
||||
|
||||
|
||||
def test_mail_users_under_mcbindustrial(client: ISPConfigClient) -> None:
|
||||
# mail_user_get accepts a filter-dict and returns a list.
|
||||
users = client.mail.user_get({"email": "%@mcbindustrial.com"})
|
||||
assert isinstance(users, list)
|
||||
# Don't assert count — just shape. Zero mailboxes is a valid state.
|
||||
for u in users:
|
||||
assert "email" in u
|
||||
Loading…
Add table
Add a link
Reference in a new issue