| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426 |
- #!/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)
- })
- }
|