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.
195 lines
7.1 KiB
Python
195 lines
7.1 KiB
Python
"""Extract ISPConfig remote method inventory from PHP sources.
|
|
|
|
Reads every ``remote.d/*.inc.php`` plus ``remoting.inc.php`` and dumps a
|
|
structured inventory to ``tools/method_inventory.json``. Parses ``public
|
|
function foo($session_id, $bar)`` declarations; scans the preceding lines
|
|
for a PHPDoc block to pull param types and descriptions.
|
|
|
|
This is the generator's input and a diff target. Checking it in makes
|
|
ISPConfig-upgrade deltas trivially visible in git.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
# (session_id, ...) for most methods; (username, password) for login.
|
|
# ISPConfig sigs look like ``public function foo($session_id, $params = array())``
|
|
# — the ``array()`` default means we need to match balanced parens, not
|
|
# ``[^)]*``, or we'll truncate the signature at the first close-paren. We
|
|
# also tolerate the opening ``{`` on its own line.
|
|
_METHOD_RE = re.compile(
|
|
r"^\s*public\s+function\s+(?P<name>\w+)\s*"
|
|
r"\((?P<params>(?:[^()]|\([^)]*\))*)\)\s*(?:\n\s*)?\{",
|
|
re.MULTILINE,
|
|
)
|
|
_PARAM_RE = re.compile(r"\$(\w+)(?:\s*=\s*([^,]+))?")
|
|
|
|
# Map file basename -> module grouping (for context only; generator re-groups
|
|
# by method-name prefix, not by file).
|
|
_FILE_CLASS_MAP = {
|
|
"sites.inc.php": "sites",
|
|
"dns.inc.php": "dns",
|
|
"mail.inc.php": "mail",
|
|
"client.inc.php": "client",
|
|
"server.inc.php": "server",
|
|
"monitor.inc.php": "monitor",
|
|
"admin.inc.php": "admin",
|
|
"aps.inc.php": "aps",
|
|
"domains.inc.php": "domains",
|
|
"openvz.inc.php": "openvz",
|
|
"remoting.inc.php": "core",
|
|
}
|
|
|
|
# Methods declared on the base ``remoting`` class that aren't "API methods"
|
|
# per se — they're internal helpers or lifecycle hooks. We want ``login``,
|
|
# ``logout``, and the two ``*_functions`` introspection calls; skip the rest.
|
|
_CORE_WHITELIST = {
|
|
"login",
|
|
"logout",
|
|
"get_function_list",
|
|
"get_session_token",
|
|
}
|
|
|
|
|
|
def _find_docblock(lines: list[str], line_no: int) -> str | None:
|
|
"""Walk backwards from ``line_no`` looking for the closest ``*/`` and
|
|
return the enclosing ``/** ... */`` block as raw text, if any."""
|
|
end = None
|
|
for i in range(line_no - 1, max(-1, line_no - 60), -1):
|
|
stripped = lines[i].strip()
|
|
if not stripped:
|
|
continue
|
|
if stripped.endswith("*/"):
|
|
end = i
|
|
break
|
|
# Non-comment, non-blank: no docblock for this method.
|
|
if not stripped.startswith("*") and not stripped.startswith("/*"):
|
|
return None
|
|
if end is None:
|
|
return None
|
|
for j in range(end, max(-1, end - 80), -1):
|
|
if lines[j].lstrip().startswith("/**") or lines[j].lstrip().startswith("/*"):
|
|
return "\n".join(lines[j : end + 1])
|
|
return None
|
|
|
|
|
|
def _parse_params(raw: str) -> list[dict[str, Any]]:
|
|
"""Extract ``$name`` and optional default from a PHP param list."""
|
|
out: list[dict[str, Any]] = []
|
|
for m in _PARAM_RE.finditer(raw):
|
|
name = m.group(1)
|
|
default = m.group(2).strip() if m.group(2) else None
|
|
out.append({"name": name, "default": default})
|
|
return out
|
|
|
|
|
|
def _parse_docblock(doc: str | None) -> dict[str, Any]:
|
|
if not doc:
|
|
return {"summary": None, "params": [], "return": None}
|
|
summary_lines: list[str] = []
|
|
params: list[dict[str, str]] = []
|
|
ret = None
|
|
for line in doc.splitlines():
|
|
text = line.strip()
|
|
if text.startswith("/**") or text.startswith("/*") or text == "*/":
|
|
continue
|
|
if text.startswith("*"):
|
|
text = text[1:].lstrip()
|
|
if not text:
|
|
continue
|
|
if text.startswith("@param"):
|
|
# @param int $foo description
|
|
m = re.match(r"@param\s+(\S+)\s+\$?(\w+)?\s*(.*)", text)
|
|
if m:
|
|
params.append(
|
|
{
|
|
"type": m.group(1),
|
|
"name": m.group(2) or "",
|
|
"desc": m.group(3),
|
|
}
|
|
)
|
|
elif text.startswith("@return"):
|
|
m = re.match(r"@return\s+(\S+)\s*(.*)", text)
|
|
if m:
|
|
ret = {"type": m.group(1), "desc": m.group(2)}
|
|
elif text.startswith("@"):
|
|
# Other tags (@author, @throws, etc.) ignored.
|
|
continue
|
|
else:
|
|
summary_lines.append(text)
|
|
return {
|
|
"summary": " ".join(summary_lines).strip() or None,
|
|
"params": params,
|
|
"return": ret,
|
|
}
|
|
|
|
|
|
def extract_file(path: Path) -> list[dict[str, Any]]:
|
|
src = path.read_text(encoding="utf-8", errors="replace")
|
|
lines = src.splitlines()
|
|
out: list[dict[str, Any]] = []
|
|
for m in _METHOD_RE.finditer(src):
|
|
name = m.group("name")
|
|
params_raw = m.group("params")
|
|
line_no = src[: m.start()].count("\n")
|
|
# Parse params. Skip the leading $session_id where present (that's
|
|
# the SDK's job to add) — keep it in the raw list so docstrings can
|
|
# reflect reality, but flag it.
|
|
parsed_params = _parse_params(params_raw)
|
|
doc = _find_docblock(lines, line_no)
|
|
docinfo = _parse_docblock(doc)
|
|
out.append(
|
|
{
|
|
"method": name,
|
|
"file": path.name,
|
|
"line": line_no + 1,
|
|
"raw_signature": f"{name}({params_raw.strip()})",
|
|
"params": parsed_params,
|
|
"doc": docinfo,
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def main(src_dir: str, out_path: str) -> None:
|
|
root = Path(src_dir)
|
|
records: list[dict[str, Any]] = []
|
|
for php in sorted(root.glob("*.inc.php")):
|
|
for rec in extract_file(php):
|
|
method = rec["method"]
|
|
# remoting.inc.php holds login/logout + internal helpers.
|
|
if php.name == "remoting.inc.php" and method not in _CORE_WHITELIST:
|
|
continue
|
|
rec["source_class"] = _FILE_CLASS_MAP.get(php.name, "unknown")
|
|
records.append(rec)
|
|
# De-dupe by method name (some methods live in multiple files via
|
|
# inheritance — take the remote.d/ version).
|
|
seen: dict[str, dict[str, Any]] = {}
|
|
for rec in records:
|
|
if rec["method"] not in seen or rec["file"] != "remoting.inc.php":
|
|
seen[rec["method"]] = rec
|
|
records = sorted(seen.values(), key=lambda r: (r["source_class"], r["method"]))
|
|
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
|
|
Path(out_path).write_text(json.dumps(records, indent=2) + "\n", encoding="utf-8")
|
|
# Quick stats to stderr so CI logs show what we got.
|
|
by_class: dict[str, int] = {}
|
|
for rec in records:
|
|
by_class[rec["source_class"]] = by_class.get(rec["source_class"], 0) + 1
|
|
print(f"extracted {len(records)} methods", file=sys.stderr)
|
|
for cls, n in sorted(by_class.items()):
|
|
print(f" {cls:<10} {n}", file=sys.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
src = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("ISPCONFIG_SRC", "")
|
|
out = sys.argv[2] if len(sys.argv) > 2 else "tools/method_inventory.json"
|
|
if not src:
|
|
print("usage: extract_inventory.py <php-src-dir> [out.json]", file=sys.stderr)
|
|
sys.exit(2)
|
|
main(src, out)
|