|
|
@@ -0,0 +1,426 @@
|
|
|
+#!/usr/bin/env node
|
|
|
+
|
|
|
+import chalk from "chalk"
|
|
|
+import { execSync } from "child_process"
|
|
|
+import * as fs from "fs/promises"
|
|
|
+import { globby } from "globby"
|
|
|
+import * as path from "path"
|
|
|
+import { fileURLToPath } from "url"
|
|
|
+
|
|
|
+const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
|
|
+const ROOT_DIR = path.resolve(SCRIPT_DIR, "..")
|
|
|
+const PROTO_DIR = path.join(ROOT_DIR, "proto")
|
|
|
+const PY_OUT_DIR = path.join(ROOT_DIR, "src", "generated", "grpc-python")
|
|
|
+const PY_CLIENT_DIR = path.join(PY_OUT_DIR, "client")
|
|
|
+
|
|
|
+function hasCommand(cmd) {
|
|
|
+ try {
|
|
|
+ if (process.platform === "win32") {
|
|
|
+ execSync(`where ${cmd}`, { stdio: "pipe" })
|
|
|
+ } else {
|
|
|
+ execSync(`which ${cmd}`, { stdio: "pipe" })
|
|
|
+ }
|
|
|
+ return true
|
|
|
+ } catch {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function resolvePython() {
|
|
|
+ // Allow override via env.PYTHON pointing to a specific interpreter
|
|
|
+ const envPy = process.env.PYTHON
|
|
|
+ if (envPy) {
|
|
|
+ try {
|
|
|
+ execSync(`"${envPy}" --version`, { stdio: "pipe" })
|
|
|
+ return envPy
|
|
|
+ } catch {
|
|
|
+ console.warn(chalk.yellow(`Warning: PYTHON override "${envPy}" is not usable, falling back to discovery.`))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const candidates = ["python3", "python"]
|
|
|
+ for (const c of candidates) {
|
|
|
+ if (hasCommand(c)) {
|
|
|
+ try {
|
|
|
+ execSync(`${c} --version`, { stdio: "pipe" })
|
|
|
+ return c
|
|
|
+ } catch {
|
|
|
+ // continue
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null
|
|
|
+}
|
|
|
+
|
|
|
+function checkGrpcTools(pythonExe) {
|
|
|
+ try {
|
|
|
+ execSync(`"${pythonExe}" -c "import grpc_tools"`, { stdio: "pipe" })
|
|
|
+ return true
|
|
|
+ } catch {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function ensureDir(dir) {
|
|
|
+ await fs.mkdir(dir, { recursive: true })
|
|
|
+}
|
|
|
+
|
|
|
+async function ensureInitPy(dir) {
|
|
|
+ try {
|
|
|
+ await fs.writeFile(path.join(dir, "__init__.py"), "", { flag: "wx" })
|
|
|
+ } catch {
|
|
|
+ // exists
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Parse proto files to extract service names with their source file and package.
|
|
|
+ * Returns array of:
|
|
|
+ * { serviceName: string, serviceKey: string, protoPackage: "cline"|"host", moduleBase: string }
|
|
|
+ */
|
|
|
+async function parseServicesWithFiles(protoDir, protoFiles) {
|
|
|
+ const services = []
|
|
|
+ for (const relPath of protoFiles) {
|
|
|
+ const full = path.join(protoDir, relPath)
|
|
|
+ const content = await fs.readFile(full, "utf8")
|
|
|
+ const pkg = relPath.startsWith("host/") ? "host" : "cline"
|
|
|
+ const moduleBase = path.basename(relPath, ".proto")
|
|
|
+ const serviceRe = /service\s+(\w+Service)\s*\{([\s\S]*?)\}/g
|
|
|
+ for (const m of content.matchAll(serviceRe)) {
|
|
|
+ const serviceName = m[1] // e.g., TaskService
|
|
|
+ const serviceKey = serviceName.replace(/Service$/, "").toLowerCase() // task
|
|
|
+ const body = m[2]
|
|
|
+ const methodRe = /rpc\s+(\w+)\s*\((stream\s)?([\w.]+)\)\s*returns\s*\((stream\s)?([\w.]+)\)/g
|
|
|
+ const methods = []
|
|
|
+ for (const mm of body.matchAll(methodRe)) {
|
|
|
+ methods.push({
|
|
|
+ name: mm[1],
|
|
|
+ isRequestStreaming: !!mm[2],
|
|
|
+ requestType: mm[3],
|
|
|
+ isResponseStreaming: !!mm[4],
|
|
|
+ responseType: mm[5],
|
|
|
+ })
|
|
|
+ }
|
|
|
+ services.push({ serviceName, serviceKey, protoPackage: pkg, moduleBase, methods })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return services
|
|
|
+}
|
|
|
+
|
|
|
+function upperFirst(s) {
|
|
|
+ return s.length ? s[0].toUpperCase() + s.slice(1) : s
|
|
|
+}
|
|
|
+
|
|
|
+async function generateConnectionPy(outDir) {
|
|
|
+ const content = `# AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
|
|
|
+# Generated by scripts/build-python-proto.mjs
|
|
|
+
|
|
|
+import grpc
|
|
|
+import time
|
|
|
+from typing import Optional
|
|
|
+
|
|
|
+class ConnectionManager:
|
|
|
+ def __init__(self, address: str, timeout: float = 30.0):
|
|
|
+ self.address = address
|
|
|
+ self.timeout = timeout
|
|
|
+ self._channel: Optional[grpc.Channel] = None
|
|
|
+
|
|
|
+ def connect(self) -> None:
|
|
|
+ if self._channel is not None:
|
|
|
+ return
|
|
|
+ self._channel = grpc.insecure_channel(self.address)
|
|
|
+ # Wait for channel to be ready within timeout
|
|
|
+ grpc.channel_ready_future(self._channel).result(timeout=self.timeout)
|
|
|
+
|
|
|
+ def disconnect(self) -> None:
|
|
|
+ if self._channel is not None:
|
|
|
+ self._channel.close()
|
|
|
+ self._channel = None
|
|
|
+
|
|
|
+ @property
|
|
|
+ def channel(self) -> Optional[grpc.Channel]:
|
|
|
+ return self._channel
|
|
|
+
|
|
|
+ def is_connected(self) -> bool:
|
|
|
+ return self._channel is not None
|
|
|
+`
|
|
|
+ await fs.mkdir(outDir, { recursive: true })
|
|
|
+ await fs.writeFile(path.join(outDir, "connection.py"), content)
|
|
|
+ await ensureInitPy(outDir)
|
|
|
+}
|
|
|
+
|
|
|
+async function generateClineClientPy(outDir, services) {
|
|
|
+ // Import per-service wrapper clients
|
|
|
+ const importLines = []
|
|
|
+ const seen = new Set()
|
|
|
+ for (const s of services) {
|
|
|
+ const fileBase = `${s.serviceKey}_client`
|
|
|
+ const className = `${s.serviceName.replace(/Service$/, "")}Client`
|
|
|
+ const importKey = `${fileBase}:${className}`
|
|
|
+ if (!seen.has(importKey)) {
|
|
|
+ importLines.push(`from .services.${fileBase} import ${className}`)
|
|
|
+ seen.add(importKey)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Build wrapper initializations on connect (like Go New<Service>Client)
|
|
|
+ const initLines = services.map((s) => {
|
|
|
+ const shortName = s.serviceName.replace(/Service$/, "") // Task
|
|
|
+ const className = `${shortName}Client`
|
|
|
+ return ` self.${shortName} = ${className}(self._conn.channel)`
|
|
|
+ })
|
|
|
+
|
|
|
+ // Build attribute resets on disconnect
|
|
|
+ const nilLines = services.map((s) => {
|
|
|
+ const shortName = s.serviceName.replace(/Service$/, "")
|
|
|
+ return ` self.${shortName} = None`
|
|
|
+ })
|
|
|
+
|
|
|
+ const content = `# AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
|
|
|
+# Generated by scripts/build-python-proto.mjs
|
|
|
+
|
|
|
+from typing import Optional
|
|
|
+
|
|
|
+import grpc
|
|
|
+from .connection import ConnectionManager
|
|
|
+${importLines.join("\n")}
|
|
|
+
|
|
|
+class ClineClient:
|
|
|
+ """
|
|
|
+ Unified Python client analogous to src/generated/grpc-go/client/ClineClient.
|
|
|
+
|
|
|
+ Usage:
|
|
|
+ client = ClineClient("localhost:17611")
|
|
|
+ client.connect()
|
|
|
+ # Call wrappers, e.g.: client.Task.SomeRpc(...)
|
|
|
+ client.disconnect()
|
|
|
+ """
|
|
|
+
|
|
|
+ def __init__(self, address: str, timeout: float = 30.0):
|
|
|
+ self._conn = ConnectionManager(address, timeout=timeout)
|
|
|
+ self._connected = False
|
|
|
+
|
|
|
+${services.map((s) => ` self.${s.serviceName.replace(/Service$/, "")}: Optional[object] = None`).join("\n")}
|
|
|
+
|
|
|
+ def connect(self) -> None:
|
|
|
+ if self._connected:
|
|
|
+ return
|
|
|
+ self._conn.connect()
|
|
|
+${initLines.join("\n")}
|
|
|
+ self._connected = True
|
|
|
+
|
|
|
+ def disconnect(self) -> None:
|
|
|
+ if not self._connected:
|
|
|
+ return
|
|
|
+ self._conn.disconnect()
|
|
|
+${nilLines.join("\n")}
|
|
|
+ self._connected = False
|
|
|
+
|
|
|
+ def is_connected(self) -> bool:
|
|
|
+ return self._connected
|
|
|
+
|
|
|
+ @property
|
|
|
+ def channel(self) -> Optional[grpc.Channel]:
|
|
|
+ return self._conn.channel
|
|
|
+`
|
|
|
+ const clientDir = outDir
|
|
|
+ await fs.mkdir(clientDir, { recursive: true })
|
|
|
+ await fs.writeFile(path.join(clientDir, "cline_client.py"), content)
|
|
|
+}
|
|
|
+
|
|
|
+async function generatePythonClient(protoDir, pyOutDir, clientDir, protoFiles) {
|
|
|
+ // Ensure package structure for client
|
|
|
+ await fs.mkdir(clientDir, { recursive: true })
|
|
|
+ await ensureInitPy(pyOutDir)
|
|
|
+ await ensureInitPy(clientDir)
|
|
|
+
|
|
|
+ const services = await parseServicesWithFiles(protoDir, protoFiles)
|
|
|
+
|
|
|
+ // connection.py
|
|
|
+ await generateConnectionPy(clientDir)
|
|
|
+
|
|
|
+ // services/ per-service wrappers (mirror Go client/services)
|
|
|
+ const servicesDir = path.join(clientDir, "services")
|
|
|
+ await fs.mkdir(servicesDir, { recursive: true })
|
|
|
+ await ensureInitPy(servicesDir)
|
|
|
+ await generateServiceClientsPy(servicesDir, services)
|
|
|
+
|
|
|
+ // cline_client.py (unified that composes service wrappers)
|
|
|
+ await generateClineClientPy(clientDir, services)
|
|
|
+}
|
|
|
+
|
|
|
+async function generateServiceClientsPy(outDir, services) {
|
|
|
+ await fs.mkdir(outDir, { recursive: true })
|
|
|
+ await ensureInitPy(outDir)
|
|
|
+
|
|
|
+ for (const s of services) {
|
|
|
+ const shortName = s.serviceName.replace(/Service$/, "") // Task
|
|
|
+ const className = `${shortName}Client`
|
|
|
+ const fileName = `${s.serviceKey}_client.py`
|
|
|
+
|
|
|
+ const aliasPb2 = `${s.protoPackage}_${s.moduleBase}_pb2`
|
|
|
+ const aliasGrpc = `${s.protoPackage}_${s.moduleBase}_pb2_grpc`
|
|
|
+
|
|
|
+ const methodLines = s.methods
|
|
|
+ .map((m) => {
|
|
|
+ const reqTypeName = m.requestType.split(".").pop()
|
|
|
+ const respTypeName = m.responseType.split(".").pop()
|
|
|
+ if (m.isResponseStreaming) {
|
|
|
+ return `
|
|
|
+ def ${m.name}(self, req):
|
|
|
+ """
|
|
|
+ Server-streaming RPC.
|
|
|
+ :param req: ${aliasPb2}.${reqTypeName}
|
|
|
+ :return: iterator of ${aliasPb2}.${respTypeName}
|
|
|
+ """
|
|
|
+ return self._stub.${m.name}(req)`
|
|
|
+ } else {
|
|
|
+ return `
|
|
|
+ def ${m.name}(self, req):
|
|
|
+ """
|
|
|
+ Unary RPC.
|
|
|
+ :param req: ${aliasPb2}.${reqTypeName}
|
|
|
+ :return: ${aliasPb2}.${respTypeName}
|
|
|
+ """
|
|
|
+ return self._stub.${m.name}(req)`
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .join("\n")
|
|
|
+
|
|
|
+ const content = `# AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
|
|
|
+# Generated by scripts/build-python-proto.mjs
|
|
|
+
|
|
|
+import grpc
|
|
|
+from ${s.protoPackage} import ${s.moduleBase}_pb2 as ${aliasPb2}
|
|
|
+from ${s.protoPackage} import ${s.moduleBase}_pb2_grpc as ${aliasGrpc}
|
|
|
+
|
|
|
+class ${className}:
|
|
|
+ def __init__(self, channel: grpc.Channel):
|
|
|
+ self._stub = ${aliasGrpc}.${s.serviceName}Stub(channel)
|
|
|
+${methodLines}
|
|
|
+`
|
|
|
+ await fs.writeFile(path.join(outDir, fileName), content)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function generatePyproject(outDir) {
|
|
|
+ const content = `[build-system]
|
|
|
+requires = ["setuptools>=68", "wheel"]
|
|
|
+build-backend = "setuptools.build_meta"
|
|
|
+
|
|
|
+[project]
|
|
|
+name = "cline-grpc-python"
|
|
|
+version = "0.1.0"
|
|
|
+description = "Generated Python gRPC stubs and client wrappers for Cline protos"
|
|
|
+license = { text: "Apache-2.0" }
|
|
|
+requires-python = ">=3.9"
|
|
|
+dependencies = [
|
|
|
+ "grpcio>=1.56.0",
|
|
|
+ "protobuf>=4.21.0"
|
|
|
+]
|
|
|
+
|
|
|
+[tool.setuptools.packages.find]
|
|
|
+where = ["."]
|
|
|
+`
|
|
|
+ await fs.writeFile(path.join(outDir, "pyproject.toml"), content)
|
|
|
+}
|
|
|
+
|
|
|
+async function main() {
|
|
|
+ console.log(chalk.cyan("Starting Python protobuf code generation..."))
|
|
|
+
|
|
|
+ // Verify proto dir exists
|
|
|
+ try {
|
|
|
+ const stat = await fs.stat(PROTO_DIR)
|
|
|
+ if (!stat.isDirectory()) {
|
|
|
+ console.error(chalk.red(`Proto directory is not a folder: ${PROTO_DIR}`))
|
|
|
+ process.exit(1)
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ console.error(chalk.red(`Proto directory not found: ${PROTO_DIR}`))
|
|
|
+ process.exit(1)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Resolve Python
|
|
|
+ const python = resolvePython()
|
|
|
+ if (!python) {
|
|
|
+ console.error(
|
|
|
+ chalk.red("Python not found on PATH. Please install Python 3 and ensure it is available (python3 or python)."),
|
|
|
+ )
|
|
|
+ process.exit(1)
|
|
|
+ }
|
|
|
+ console.log(chalk.green(`✓ Using Python executable: ${python}`))
|
|
|
+
|
|
|
+ // Check grpcio-tools
|
|
|
+ if (!checkGrpcTools(python)) {
|
|
|
+ console.error(chalk.red("Missing dependency: grpcio-tools"))
|
|
|
+ console.log(chalk.yellow("Install with:"))
|
|
|
+ console.log(chalk.yellow(` ${python} -m pip install grpcio-tools --user --break-system-packages`))
|
|
|
+ process.exit(1)
|
|
|
+ }
|
|
|
+ console.log(chalk.green("✓ grpcio-tools available"))
|
|
|
+
|
|
|
+ // Discover proto files
|
|
|
+ const protoFiles = await globby("**/*.proto", { cwd: PROTO_DIR })
|
|
|
+ if (!protoFiles.length) {
|
|
|
+ console.error(chalk.red("No .proto files found under ./proto"))
|
|
|
+ process.exit(1)
|
|
|
+ }
|
|
|
+ console.log(chalk.cyan(`Found ${protoFiles.length} proto files`))
|
|
|
+
|
|
|
+ // Ensure output directory
|
|
|
+ await ensureDir(PY_OUT_DIR)
|
|
|
+
|
|
|
+ // Build and run protoc command via grpc_tools
|
|
|
+ const quoted = (s) => `"${s}"`
|
|
|
+ const pythonCmd = quoted(python)
|
|
|
+ const cmd =
|
|
|
+ `${pythonCmd} -m grpc_tools.protoc ` +
|
|
|
+ `-I ${quoted(PROTO_DIR)} ` +
|
|
|
+ `--python_out=${quoted(PY_OUT_DIR)} ` +
|
|
|
+ `--grpc_python_out=${quoted(PY_OUT_DIR)} ` +
|
|
|
+ protoFiles.map((f) => quoted(f)).join(" ")
|
|
|
+
|
|
|
+ try {
|
|
|
+ console.log(chalk.cyan(`Generating Python code into ${PY_OUT_DIR}...`))
|
|
|
+ execSync(cmd, { cwd: ROOT_DIR, stdio: "inherit", env: process.env })
|
|
|
+ } catch (error) {
|
|
|
+ console.error(chalk.red("Error generating Python code:"), error?.message || error)
|
|
|
+ process.exit(1)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Ensure package structure (__init__.py) for imports
|
|
|
+ await ensureInitPy(PY_OUT_DIR)
|
|
|
+ try {
|
|
|
+ const clineDir = path.join(PY_OUT_DIR, "cline")
|
|
|
+ const hostDir = path.join(PY_OUT_DIR, "host")
|
|
|
+ // These may or may not exist depending on which protos are present
|
|
|
+ await fs
|
|
|
+ .stat(clineDir)
|
|
|
+ .then(() => ensureInitPy(clineDir))
|
|
|
+ .catch(() => {})
|
|
|
+ await fs
|
|
|
+ .stat(hostDir)
|
|
|
+ .then(() => ensureInitPy(hostDir))
|
|
|
+ .catch(() => {})
|
|
|
+ } catch {
|
|
|
+ // ignore
|
|
|
+ }
|
|
|
+
|
|
|
+ // Generate Python client structure analogous to src/generated/grpc-go/client
|
|
|
+ await generatePythonClient(PROTO_DIR, PY_OUT_DIR, PY_CLIENT_DIR, protoFiles)
|
|
|
+
|
|
|
+ // Generate a minimal pyproject.toml in the generated output so it can be pip-installed if desired
|
|
|
+ await generatePyproject(PY_OUT_DIR)
|
|
|
+
|
|
|
+ console.log(chalk.green("✓ Python protobuf and client code generation completed successfully!"))
|
|
|
+ console.log(chalk.cyan(`Output directory: ${PY_OUT_DIR}`))
|
|
|
+ console.log(chalk.cyan(`Client directory: ${PY_CLIENT_DIR}`))
|
|
|
+ console.log(chalk.cyan(`PyProject: ${path.join(PY_OUT_DIR, "pyproject.toml")}`))
|
|
|
+ console.log(chalk.gray("Note: To import, add the output dir to your PYTHONPATH or pip install -e src/generated/grpc-python"))
|
|
|
+}
|
|
|
+
|
|
|
+if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
+ main().catch((err) => {
|
|
|
+ console.error(chalk.red("Unexpected error in build-python-proto.mjs:"), err)
|
|
|
+ process.exit(1)
|
|
|
+ })
|
|
|
+}
|