feat: full ISPConfig remote API coverage + re-runnable generator (v0.2)

v0.1 shipped ~15 hand-audited methods across sites/dns/mail/databases/clients.
That's enough for daily ops but every new Tort Host / cWHO feature has been
hitting the wall at the edge of that coverage. This extends the SDK to wrap
every method the panel exposes — 312 of them as of Rackham 2026-04-22,
verified against the live list_functions() introspection call with only one
name-mismatch (``__construct``, a PHP lifecycle artifact, not a real API
method).

The hand-audited helpers stay where they are. Every module now has two
clearly-delimited sections: an auto-generated block at the top (emitted by
tools/gen_methods.py from tools/method_inventory.json), and a HAND-EDIT ONLY
BELOW block at the bottom that survives regeneration. Name collisions
between auto and hand always resolve in favor of the hand version — the
generator emits a ``# skipped foo: hand-audited helper takes precedence``
comment in the auto block for traceability.

Pipeline:
- tools/extract_inventory.py reads remote.*.inc.php + remoting.inc.php,
  pulls docblocks + param defaults, dumps one JSON record per method.
  Regex is balanced-paren aware so ``$params = array()`` defaults don't
  truncate the signature at the wrong close-paren (that footgun hid three
  methods from the first run — sites_aps_available_packages_list,
  sites_aps_instance_delete, openvz_vm_add_from_template).
- tools/method_inventory.json is the committed inventory — future ISPConfig
  upgrades diff against this file to see scope at a glance.
- tools/gen_methods.py groups by method-name prefix onto the module classes
  listed in the README table, emits a 1:1 Python wrapper per method with
  the original PHP filename + line number in the docstring, and ensures
  ``from typing import Any`` is present in preexisting modules before
  emitting ``Any`` type annotations.

New submodules (all auto-generated, wired into ISPConfigClient.__init__):
admin, aps, backups, cron, domains, ftp, misc, monitor, openvz, server,
shell, webdav. Existing modules (sites, dns, mail, databases, clients) got
their auto block filled in and their hand-audited helpers preserved.

Escape hatches on the top-level client:
- raw_call(method, *args) routes an arbitrary method name through the same
  session-management + retry + fault-mapping pipeline the typed wrappers
  use. Fix for "panel shipped a new method, SDK hasn't caught up" —
  callers don't have to reach back into _soap.
- list_functions() wraps get_function_list() for panel introspection.

Fault mapping widened: ``no_client_found`` and "no user account" messages
now map to NotFoundError instead of FaultError, matching the existing
``no_domain_found`` convention. Older code that caught raw FaultError
there will still work (NotFoundError extends ISPConfigError) but callers
can now catch the specific type.

Testing:
- tests/test_unit.py — 12 existing pure-unit tests pass unchanged.
- tests/test_smoke.py — extended from 4 read-only calls to 21. One probe
  per new auto-generated module plus raw_call and list_functions smoke
  tests. Methods gated behind admin permission skip gracefully with a
  documented reason (kayos is a reseller, not admin): monitor_jobqueue_count,
  sites_cron_get, sites_ftp_user_get, openvz_get_free_ip,
  quota_get_by_user. Results against Rackham 2026-04-22:
  28 passed, 5 skipped (all documented admin-only), 0 failed.
- ISPCONFIG_TEST_VERIFY_SSL=0 env-var knob added to conftest for panels
  with self-signed or mismatched certs.

Version bump 0.1.0 -> 0.2.0. README restructured into Hand-audited /
Auto-generated / Escape hatch / Footguns sections with a regeneration
recipe for future ISPConfig upgrades. Ruff per-file SLF001 ignore extended
to every submodule (submodules are all authorized callers of the client's
private ``_call`` dispatcher by design). mypy strict passes; ruff check
passes; ruff format applied across src / tools / tests.
This commit is contained in:
Kayos 2026-04-22 13:58:38 -07:00
parent 9438b4e751
commit 44ce76cb44
28 changed files with 14173 additions and 184 deletions

446
tools/gen_methods.py Normal file
View file

@ -0,0 +1,446 @@
"""Generate Python wrappers from the ISPConfig method inventory.
Reads ``tools/method_inventory.json`` and emits/updates one module per
functional area under ``src/ispconfig/``. Each module has two clearly-marked
sections:
# ---- AUTO-GENERATED START (do not hand-edit above this line) ----
... wrappers ...
# ---- AUTO-GENERATED END ----
# ---- HAND-EDIT ONLY BELOW ----
... helpers, convenience methods, etc ...
Re-running the generator replaces only the auto block; hand-edits below
the delimiter are preserved. Hand-audited helpers already in the tree
(enable_php, a_add with fix_type_bug, user_get with filter-dict
normalization, etc.) live below the delimiter and are untouched.
If an auto-generated method name collides with a hand-audited one
(detected by scanning the hand-edit block for ``def <name>(``), the
generator skips the auto wrapper the hand version wins.
Re-run flow::
python3 tools/extract_inventory.py <php-src> tools/method_inventory.json
python3 tools/gen_methods.py
ruff format src/ tools/
git diff --stat
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent.parent
INVENTORY = ROOT / "tools" / "method_inventory.json"
SRC = ROOT / "src" / "ispconfig"
AUTO_START = "# ---- AUTO-GENERATED START (do not hand-edit above this line) ----"
AUTO_END = "# ---- AUTO-GENERATED END ----"
HAND_MARKER = "# ---- HAND-EDIT ONLY BELOW ----"
# Routing: method-name prefix -> (module filename, class name, method-name
# stripping rule). The stripping rule strips a prefix from the Python method
# name when we can keep it; we don't, for auto-generated wrappers, so the
# generator emits ``method_foo_bar`` as-is. (Hand-audited helpers strip the
# prefix for ergonomics — kept below the delimiter.)
#
# Order matters: first match wins. Longer prefixes first.
_ROUTES: list[tuple[str, str, str]] = [
# sites.*
("sites_web_vhost_aliasdomain_", "sites.py", "SitesModule"),
("sites_web_vhost_subdomain_", "sites.py", "SitesModule"),
("sites_web_aliasdomain_", "sites.py", "SitesModule"),
("sites_web_subdomain_", "sites.py", "SitesModule"),
("sites_web_domain_backup", "backups.py", "BackupsModule"),
("sites_web_domain_", "sites.py", "SitesModule"),
("sites_web_folder_user_", "sites.py", "SitesModule"),
("sites_web_folder_", "sites.py", "SitesModule"),
("sites_database_user_", "databases.py", "DatabasesModule"),
("sites_database_", "databases.py", "DatabasesModule"),
("sites_ftp_user_", "ftp.py", "FtpModule"),
("sites_shell_user_", "shell.py", "ShellModule"),
("sites_cron_", "cron.py", "CronModule"),
("sites_webdav_user_", "webdav.py", "WebdavModule"),
("sites_aps_", "aps.py", "ApsModule"),
("client_", "clients.py", "ClientsModule"),
("mail_", "mail.py", "MailModule"),
("mailquota_", "mail.py", "MailModule"),
("dns_", "dns.py", "DnsModule"),
("server_", "server.py", "ServerModule"),
("monitor_", "monitor.py", "MonitorModule"),
("domains_", "domains.py", "DomainsModule"),
("openvz_", "openvz.py", "OpenvzModule"),
# admin / misc buckets
("sys_datalog_", "admin.py", "AdminModule"),
("system_config_", "admin.py", "AdminModule"),
("config_value_", "admin.py", "AdminModule"),
("update_record_permissions", "admin.py", "AdminModule"),
# quotas and misc helpers from sites.inc.php that don't match the
# `sites_*` prefix
("quota_get_by_user", "misc.py", "MiscModule"),
("databasequota_get_by_user", "misc.py", "MiscModule"),
("ftptrafficquota_data", "misc.py", "MiscModule"),
("trafficquota_get_by_user", "misc.py", "MiscModule"),
("client_get_sites_by_user", "misc.py", "MiscModule"),
]
# Modules that already exist in the tree with a hand-audited class. For
# these, we append the auto block to an existing class body rather than
# writing a fresh module.
_PREEXISTING = {
"sites.py": "SitesModule",
"dns.py": "DnsModule",
"mail.py": "MailModule",
"databases.py": "DatabasesModule",
"clients.py": "ClientsModule",
}
# Methods we skip at generation time: ``login`` and ``logout`` are already
# on the top-level client, and ``get_function_list`` is a no-arg
# introspection call we expose via ``ISPConfigClient.list_functions()``.
_SKIP_METHODS = {"login", "logout", "get_function_list"}
def route(method: str) -> tuple[str, str] | None:
for prefix, fname, cls in _ROUTES:
if method == prefix or method.startswith(prefix):
return fname, cls
return None
def _py_method_name(method: str, prefix: str) -> str:
"""Strip the routed prefix from a method name if it's safe to.
For the auto-generated wrappers we keep the full PHP method name as
the Python method name. This is verbose but unambiguous and matches
``raw_call`` semantics so you can grep for the exact PHP string.
Hand-audited helpers may provide shorter names; they live below the
delimiter and take precedence.
"""
return method # keep the full PHP name; stripping invites collisions
def _docstring(rec: dict[str, Any]) -> list[str]:
"""Return docstring lines, indented with 8 spaces (method-body level)."""
indent = " "
lines = [f'{indent}"""']
summary = rec["doc"].get("summary") or f"Auto-generated wrapper for ``{rec['method']}``."
# Escape embedded triple quotes defensively.
summary = summary.replace('"""', "'''")
lines.append(f"{indent}{summary}")
lines.append("")
lines.append(f"{indent}Source: ``{rec['file']}`` line {rec['line']}.")
sig_params = [p["name"] for p in rec["params"] if p["name"] != "session_id"]
if sig_params:
lines.append(f"{indent}PHP signature: ``{rec['raw_signature']}``.")
if rec["doc"]["params"]:
lines.append("")
lines.append(f"{indent}Params (from PHPDoc):")
for p in rec["doc"]["params"]:
pname = p.get("name") or "?"
if pname == "session_id":
continue
ptype = p.get("type", "?")
desc = (p.get("desc") or "").replace('"""', "'''")
if desc:
lines.append(f"{indent} {pname} ({ptype}): {desc}")
else:
lines.append(f"{indent} {pname} ({ptype})")
if rec["doc"].get("return"):
lines.append("")
ret_type = rec["doc"]["return"].get("type", "Any")
ret_desc = (rec["doc"]["return"].get("desc") or "").replace('"""', "'''")
suffix = f" - {ret_desc}" if ret_desc else ""
lines.append(f"{indent}Returns: {ret_type}{suffix}")
lines.append("")
lines.append(f"{indent}AUTO-GENERATED - param shapes may need verification against your")
lines.append(f"{indent}ISPConfig version. File issues at Sulkta-Coop/ispconfig-py.")
lines.append(f'{indent}"""')
return lines
_PHP_TYPE_MAP = {
"int": "int",
"integer": "int",
"string": "str",
"bool": "bool",
"boolean": "bool",
"array": "dict[str, Any] | list[Any]",
"mixed": "Any",
"float": "float",
"double": "float",
}
def _py_param_type(phpdoc_params: list[dict[str, str]], name: str) -> str:
for p in phpdoc_params:
if p.get("name") == name:
t = (p.get("type") or "").strip().lower().split("|")[0]
return _PHP_TYPE_MAP.get(t, "Any")
return "Any"
def _emit_method(rec: dict[str, Any]) -> list[str]:
method = rec["method"]
# Parameters after session_id.
params = [p for p in rec["params"] if p["name"] != "session_id"]
sig_parts = ["self"]
arg_encodes: list[str] = []
for p in params:
py_type = _py_param_type(rec["doc"]["params"], p["name"])
default = p["default"]
if default is not None:
# PHP defaults are best-effort translated.
default_py = _translate_php_default(default)
# PEP 484: a None default needs ``| None`` on the annotation.
# Any already includes None, so no change needed for Any.
if default_py == "None" and py_type not in ("Any", "dict[str, Any] | list[Any]"):
py_type = f"{py_type} | None"
sig_parts.append(f"{p['name']}: {py_type} = {default_py}")
else:
sig_parts.append(f"{p['name']}: {py_type}")
arg_encodes.append(f'("{p["name"]}", {p["name"]})')
signature = ", ".join(sig_parts)
lines: list[str] = []
lines.append(f" def {method}({signature}) -> Any:")
lines.extend(_docstring(rec))
if arg_encodes:
args_str = ", ".join(arg_encodes)
lines.append(f' return self._c._call("{method}", {args_str})')
else:
lines.append(f' return self._c._call("{method}")')
lines.append("")
return lines
def _translate_php_default(value: str) -> str:
v = value.strip()
low = v.lower()
if low in ("null",):
return "None"
if low == "true":
return "True"
if low == "false":
return "False"
if low in ("array()", "[]"):
return "None" # PHP empty-array default -> None in Python signature
# Numeric literals pass through.
if re.match(r"^-?\d+(\.\d+)?$", v):
return v
# Quoted strings.
if (v.startswith("'") and v.endswith("'")) or (v.startswith('"') and v.endswith('"')):
inner = v[1:-1]
return '"' + inner.replace("\\", "\\\\").replace('"', '\\"') + '"'
# Unknown → fall back to None (safest default for optional params).
return "None"
# -----------------------------------------------------------------------
# Module file handling
# -----------------------------------------------------------------------
def _module_header(fname: str, cls: str) -> str:
"""Stock module header for NEW module files (not preexisting)."""
stem = fname.replace(".py", "")
mod_title = stem.replace("_", " ").title()
return (
f'"""``{stem}.*`` — auto-generated ISPConfig remote-API wrappers.\n\n'
f"This module is produced by ``tools/gen_methods.py`` from the\n"
f"``tools/method_inventory.json`` catalog. Hand-edits go below the\n"
f"``{HAND_MARKER.strip('# ')}`` marker — they survive regeneration.\n"
f'"""\n\n'
f"from __future__ import annotations\n\n"
f"from typing import TYPE_CHECKING, Any\n\n"
f"if TYPE_CHECKING:\n"
f" from .client import ISPConfigClient\n\n\n"
f"class {cls}:\n"
f' """Auto-generated module: {mod_title}.\n\n'
f" All methods below the ``AUTO-GENERATED START`` marker are produced\n"
f" by ``tools/gen_methods.py``. Do not hand-edit that block — changes\n"
f" will be overwritten on the next regeneration. Add helpers and\n"
f" overrides below the ``HAND-EDIT ONLY BELOW`` marker instead.\n"
f' """\n\n'
f" def __init__(self, client: ISPConfigClient) -> None:\n"
f" self._c = client\n\n"
)
def _find_existing_auto_block(text: str) -> tuple[int, int] | None:
"""Return (start_line_idx, end_line_idx) of the existing auto block, if any."""
lines = text.splitlines()
start = end = None
for i, line in enumerate(lines):
if AUTO_START in line:
start = i
if AUTO_END in line and start is not None:
end = i
break
if start is None or end is None:
return None
return start, end
def _find_class_body_insertion(text: str, cls: str) -> int | None:
"""Find the line index of the first blank line AFTER the class's __init__
(a sensible spot to inject the auto block in a preexisting module).
"""
lines = text.splitlines()
in_class = False
saw_init = False
for i, line in enumerate(lines):
if re.match(rf"class\s+{cls}\b", line):
in_class = True
continue
if in_class and "def __init__" in line:
saw_init = True
if saw_init and line.strip() == "":
return i + 1
return None
def _extract_hand_method_names(text: str, auto_end: int | None) -> set[str]:
"""Scan everything AFTER the auto block for ``def <name>(`` so the
generator can skip methods a hand-audited helper already claims.
"""
lines = text.splitlines()
start = (auto_end + 1) if auto_end is not None else 0
names: set[str] = set()
for line in lines[start:]:
m = re.match(r"\s+def\s+(\w+)\s*\(", line)
if m:
names.add(m.group(1))
return names
def _render_auto_block(records: list[dict[str, Any]], hand_names: set[str]) -> list[str]:
lines: list[str] = []
lines.append(f" {AUTO_START}")
lines.append(" # Regenerate with: python3 tools/gen_methods.py")
lines.append("")
emitted_names: set[str] = set()
for rec in sorted(records, key=lambda r: r["method"]):
if rec["method"] in hand_names:
lines.append(f" # skipped {rec['method']}: hand-audited helper below takes precedence")
lines.append("")
continue
if rec["method"] in emitted_names:
continue
emitted_names.add(rec["method"])
lines.extend(_emit_method(rec))
lines.append(f" {AUTO_END}")
lines.append("")
lines.append(f" {HAND_MARKER}")
lines.append("")
return lines
def _ensure_any_imported(text: str) -> str:
"""Make sure ``Any`` is imported from ``typing`` — auto-generated wrappers
always need it. Leaves other imports untouched; idempotent.
"""
if re.search(r"^from\s+typing\s+import[^\n]*\bAny\b", text, re.MULTILINE):
return text
m = re.search(r"^(from\s+typing\s+import\s+)([^\n]+)$", text, re.MULTILINE)
if m:
current = m.group(2)
# Append ``Any``; keep symbols sorted lexically for stable diffs.
symbols = sorted({s.strip() for s in current.split(",")} | {"Any"})
new_line = m.group(1) + ", ".join(symbols)
return text[: m.start()] + new_line + text[m.end() :]
# No ``from typing import`` at all — add one after ``from __future__``.
fut = re.search(r"^from\s+__future__\s+import[^\n]*$", text, re.MULTILINE)
if fut:
insertion = fut.end()
return text[:insertion] + "\n\nfrom typing import Any" + text[insertion:]
# Last resort: prepend.
return "from typing import Any\n\n" + text
def _update_existing_module(path: Path, cls: str, records: list[dict[str, Any]]) -> None:
text = path.read_text(encoding="utf-8")
text = _ensure_any_imported(text)
block = _find_existing_auto_block(text)
if block is None:
# First-time injection: find where to insert.
lines = text.splitlines()
insertion = _find_class_body_insertion(text, cls)
if insertion is None:
print(f"WARN: couldn't find insertion point in {path}", file=sys.stderr)
return
hand_names = _extract_hand_method_names(text, auto_end=None)
auto_lines = _render_auto_block(records, hand_names)
new_lines = lines[:insertion] + auto_lines + lines[insertion:]
path.write_text("\n".join(new_lines).rstrip() + "\n", encoding="utf-8")
return
start, end = block
lines = text.splitlines()
hand_names = _extract_hand_method_names(text, auto_end=end)
auto_lines = _render_auto_block(records, hand_names)
# Replace start..end+possible trailing hand-marker line; we also re-emit the
# hand marker so we own its exact placement. Consume any blank lines plus
# a HAND_MARKER (with optional trailing blank) between AUTO_END and the
# first real code below — we'll re-emit the marker ourselves at the end
# of ``auto_lines``.
after = end + 1
while after < len(lines) and lines[after].strip() == "":
after += 1
if after < len(lines) and HAND_MARKER in lines[after]:
after += 1
while after < len(lines) and lines[after].strip() == "":
after += 1
new_lines = lines[:start] + auto_lines + lines[after:]
path.write_text("\n".join(new_lines).rstrip() + "\n", encoding="utf-8")
def _write_new_module(path: Path, cls: str, records: list[dict[str, Any]]) -> None:
header = _module_header(path.name, cls)
auto_lines = _render_auto_block(records, hand_names=set())
body = header + "\n".join(auto_lines).rstrip() + "\n"
path.write_text(body, encoding="utf-8")
def main() -> None:
records = json.loads(INVENTORY.read_text(encoding="utf-8"))
SRC.mkdir(parents=True, exist_ok=True)
by_module: dict[tuple[str, str], list[dict[str, Any]]] = {}
unrouted: list[str] = []
for rec in records:
if rec["method"] in _SKIP_METHODS:
continue
routed = route(rec["method"])
if routed is None:
unrouted.append(rec["method"])
# Default bucket = misc.py
routed = ("misc.py", "MiscModule")
key = routed
by_module.setdefault(key, []).append(rec)
for (fname, cls), recs in sorted(by_module.items()):
path = SRC / fname
if path.exists() and fname in _PREEXISTING:
_update_existing_module(path, cls, recs)
action = "updated"
elif path.exists():
_update_existing_module(path, cls, recs)
action = "refreshed"
else:
_write_new_module(path, cls, recs)
action = "created"
print(f"{action:<10} {fname:<16} ({len(recs)} methods)")
if unrouted:
print(f"\nUNROUTED ({len(unrouted)}) — filed under misc.py:", file=sys.stderr)
for m in unrouted:
print(f" {m}", file=sys.stderr)
if __name__ == "__main__":
main()