aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/check_stubs.py172
1 files changed, 172 insertions, 0 deletions
diff --git a/scripts/check_stubs.py b/scripts/check_stubs.py
new file mode 100644
index 0000000..5d5d7e4
--- /dev/null
+++ b/scripts/check_stubs.py
@@ -0,0 +1,172 @@
+#!/usr/bin/env python3
+"""Check that stubs/ match the real Open WebUI signatures.
+
+Fetches the relevant Open WebUI source files from GitHub at a given ref
+(default: the tag from `required_open_webui_version` in comfier.py), parses
+them with `ast` (nothing is imported or installed) and compares function
+signatures with the .pyi stubs: async-ness, parameter names/kinds and whether
+each parameter has a default. Annotations are not compared.
+"""
+
+import argparse
+import ast
+import pathlib
+import re
+import sys
+import urllib.request
+
+ROOT = pathlib.Path(__file__).parent.parent
+RAW = "https://raw.githubusercontent.com/open-webui/open-webui/{ref}/backend/open_webui/{path}.py"
+
+# (stub file, upstream module path, owner class or None, function names)
+CHECKS: list[tuple[str, str, str | None, list[str]]] = [
+ ("models/config", "models/config", "Config", ["get"]),
+ ("models/users", "models/users", "UsersTable", ["get_user_by_id"]),
+ ("routers/images", "routers/images", None, ["get_image_config", "upload_image"]),
+]
+
+Fn = ast.FunctionDef | ast.AsyncFunctionDef
+
+
+def default_ref() -> str:
+ header = (ROOT / "comfier.py").read_text(encoding="utf-8")
+ match = re.search(r"^required_open_webui_version:\s*(\S+)", header, re.MULTILINE)
+ if not match:
+ sys.exit("Could not find required_open_webui_version in comfier.py")
+ return "v" + match.group(1).lstrip("v")
+
+
+def fetch(ref: str, path: str) -> str:
+ url = RAW.format(ref=ref, path=path)
+ with urllib.request.urlopen(url) as resp:
+ return resp.read().decode("utf-8")
+
+
+def find_fn(tree: ast.Module, owner: str | None, name: str) -> Fn | None:
+ body: list[ast.stmt] = tree.body
+ if owner is not None:
+ cls = next(
+ (n for n in body if isinstance(n, ast.ClassDef) and n.name == owner), None
+ )
+ if cls is None:
+ return None
+ body = cls.body
+ return next(
+ (
+ n
+ for n in body
+ if isinstance(n, ast.FunctionDef | ast.AsyncFunctionDef) and n.name == name
+ ),
+ None,
+ )
+
+
+def signature(fn: Fn, drop_self: bool) -> tuple[bool, list[tuple[str, str, bool]]]:
+ """(is_async, [(name, kind, has_default)])"""
+ a = fn.args
+ params: list[tuple[str, str, bool]] = []
+ positional = [*a.posonlyargs, *a.args]
+ n_defaults = len(a.defaults)
+ for i, arg in enumerate(positional):
+ has_default = i >= len(positional) - n_defaults
+ kind = "posonly" if arg in a.posonlyargs else "pos"
+ params.append((arg.arg, kind, has_default))
+ if a.vararg:
+ params.append((a.vararg.arg, "vararg", False))
+ for arg, default in zip(a.kwonlyargs, a.kw_defaults, strict=True):
+ params.append((arg.arg, "kwonly", default is not None))
+ if a.kwarg:
+ params.append((a.kwarg.arg, "kwarg", False))
+ if drop_self and params and params[0][0] in ("self", "cls"):
+ params = params[1:]
+ return isinstance(fn, ast.AsyncFunctionDef), params
+
+
+def check_users_singleton(upstream: ast.Module, stub: ast.Module) -> list[str]:
+ """`Users` must be an instance of UsersTable in both."""
+ problems: list[str] = []
+ real = any(
+ isinstance(n, ast.Assign)
+ and any(isinstance(t, ast.Name) and t.id == "Users" for t in n.targets)
+ and isinstance(n.value, ast.Call)
+ and isinstance(n.value.func, ast.Name)
+ and n.value.func.id == "UsersTable"
+ for n in upstream.body
+ )
+ stubbed = any(
+ isinstance(n, ast.AnnAssign)
+ and isinstance(n.target, ast.Name)
+ and n.target.id == "Users"
+ and isinstance(n.annotation, ast.Name)
+ and n.annotation.id == "UsersTable"
+ for n in stub.body
+ )
+ if not real:
+ problems.append("models/users: upstream no longer has `Users = UsersTable()`")
+ if not stubbed:
+ problems.append("models/users: stub lacks `Users: UsersTable`")
+ return problems
+
+
+def is_static(fn: Fn) -> bool:
+ return any(
+ isinstance(d, ast.Name) and d.id == "staticmethod" for d in fn.decorator_list
+ )
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0])
+ parser.add_argument(
+ "--ref", default=None, help="git ref/tag (default: from comfier.py)"
+ )
+ args = parser.parse_args()
+ ref = args.ref or default_ref()
+ print(f"Checking stubs against open-webui @ {ref}")
+
+ problems: list[str] = []
+ for stub_path, upstream_path, owner, names in CHECKS:
+ stub_file = ROOT / "stubs" / "open_webui" / f"{stub_path}.pyi"
+ stub_tree = ast.parse(stub_file.read_text(encoding="utf-8"))
+ try:
+ upstream_tree = ast.parse(fetch(ref, upstream_path))
+ except Exception as err:
+ problems.append(f"{upstream_path}: could not fetch/parse upstream ({err})")
+ continue
+
+ for name in names:
+ label = f"{upstream_path}:{owner + '.' if owner else ''}{name}"
+ real_fn = find_fn(upstream_tree, owner, name)
+ stub_fn = find_fn(stub_tree, owner, name)
+ if real_fn is None:
+ problems.append(f"{label}: missing upstream")
+ continue
+ if stub_fn is None:
+ problems.append(f"{label}: missing in stub")
+ continue
+ real_sig = signature(
+ real_fn, drop_self=owner is not None and not is_static(real_fn)
+ )
+ stub_sig = signature(
+ stub_fn, drop_self=owner is not None and not is_static(stub_fn)
+ )
+ if real_sig != stub_sig:
+ problems.append(
+ f"{label}:\n upstream: {real_sig}\n stub: {stub_sig}"
+ )
+ else:
+ print(f" ok {label}")
+
+ if owner == "UsersTable":
+ problems += check_users_singleton(upstream_tree, stub_tree)
+
+ if problems:
+ print("\nMismatches:")
+ for p in problems:
+ print(f" - {p}")
+ return 1
+ print("All stubs match.")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())