"""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\w+)\s*" r"\((?P(?:[^()]|\([^)]*\))*)\)\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 [out.json]", file=sys.stderr) sys.exit(2) main(src, out)