build-python-proto.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. #!/usr/bin/env node
  2. import chalk from "chalk"
  3. import { execSync } from "child_process"
  4. import * as fs from "fs/promises"
  5. import { globby } from "globby"
  6. import * as path from "path"
  7. import { fileURLToPath } from "url"
  8. const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
  9. const ROOT_DIR = path.resolve(SCRIPT_DIR, "..")
  10. const PROTO_DIR = path.join(ROOT_DIR, "proto")
  11. const PY_OUT_DIR = path.join(ROOT_DIR, "src", "generated", "grpc-python")
  12. const PY_CLIENT_DIR = path.join(PY_OUT_DIR, "client")
  13. function hasCommand(cmd) {
  14. try {
  15. if (process.platform === "win32") {
  16. execSync(`where ${cmd}`, { stdio: "pipe" })
  17. } else {
  18. execSync(`which ${cmd}`, { stdio: "pipe" })
  19. }
  20. return true
  21. } catch {
  22. return false
  23. }
  24. }
  25. function resolvePython() {
  26. // Allow override via env.PYTHON pointing to a specific interpreter
  27. const envPy = process.env.PYTHON
  28. if (envPy) {
  29. try {
  30. execSync(`"${envPy}" --version`, { stdio: "pipe" })
  31. return envPy
  32. } catch {
  33. console.warn(chalk.yellow(`Warning: PYTHON override "${envPy}" is not usable, falling back to discovery.`))
  34. }
  35. }
  36. const candidates = ["python3", "python"]
  37. for (const c of candidates) {
  38. if (hasCommand(c)) {
  39. try {
  40. execSync(`${c} --version`, { stdio: "pipe" })
  41. return c
  42. } catch {
  43. // continue
  44. }
  45. }
  46. }
  47. return null
  48. }
  49. function checkGrpcTools(pythonExe) {
  50. try {
  51. execSync(`"${pythonExe}" -c "import grpc_tools"`, { stdio: "pipe" })
  52. return true
  53. } catch {
  54. return false
  55. }
  56. }
  57. async function ensureDir(dir) {
  58. await fs.mkdir(dir, { recursive: true })
  59. }
  60. async function ensureInitPy(dir) {
  61. try {
  62. await fs.writeFile(path.join(dir, "__init__.py"), "", { flag: "wx" })
  63. } catch {
  64. // exists
  65. }
  66. }
  67. /**
  68. * Parse proto files to extract service names with their source file and package.
  69. * Returns array of:
  70. * { serviceName: string, serviceKey: string, protoPackage: "cline"|"host", moduleBase: string }
  71. */
  72. async function parseServicesWithFiles(protoDir, protoFiles) {
  73. const services = []
  74. for (const relPath of protoFiles) {
  75. const full = path.join(protoDir, relPath)
  76. const content = await fs.readFile(full, "utf8")
  77. const pkg = relPath.startsWith("host/") ? "host" : "cline"
  78. const moduleBase = path.basename(relPath, ".proto")
  79. const serviceRe = /service\s+(\w+Service)\s*\{([\s\S]*?)\}/g
  80. for (const m of content.matchAll(serviceRe)) {
  81. const serviceName = m[1] // e.g., TaskService
  82. const serviceKey = serviceName.replace(/Service$/, "").toLowerCase() // task
  83. const body = m[2]
  84. const methodRe = /rpc\s+(\w+)\s*\((stream\s)?([\w.]+)\)\s*returns\s*\((stream\s)?([\w.]+)\)/g
  85. const methods = []
  86. for (const mm of body.matchAll(methodRe)) {
  87. methods.push({
  88. name: mm[1],
  89. isRequestStreaming: !!mm[2],
  90. requestType: mm[3],
  91. isResponseStreaming: !!mm[4],
  92. responseType: mm[5],
  93. })
  94. }
  95. services.push({ serviceName, serviceKey, protoPackage: pkg, moduleBase, methods })
  96. }
  97. }
  98. return services
  99. }
  100. function upperFirst(s) {
  101. return s.length ? s[0].toUpperCase() + s.slice(1) : s
  102. }
  103. async function generateConnectionPy(outDir) {
  104. const content = `# AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
  105. # Generated by scripts/build-python-proto.mjs
  106. import grpc
  107. import time
  108. from typing import Optional
  109. class ConnectionManager:
  110. def __init__(self, address: str, timeout: float = 30.0):
  111. self.address = address
  112. self.timeout = timeout
  113. self._channel: Optional[grpc.Channel] = None
  114. def connect(self) -> None:
  115. if self._channel is not None:
  116. return
  117. self._channel = grpc.insecure_channel(self.address)
  118. # Wait for channel to be ready within timeout
  119. grpc.channel_ready_future(self._channel).result(timeout=self.timeout)
  120. def disconnect(self) -> None:
  121. if self._channel is not None:
  122. self._channel.close()
  123. self._channel = None
  124. @property
  125. def channel(self) -> Optional[grpc.Channel]:
  126. return self._channel
  127. def is_connected(self) -> bool:
  128. return self._channel is not None
  129. `
  130. await fs.mkdir(outDir, { recursive: true })
  131. await fs.writeFile(path.join(outDir, "connection.py"), content)
  132. await ensureInitPy(outDir)
  133. }
  134. async function generateClineClientPy(outDir, services) {
  135. // Import per-service wrapper clients
  136. const importLines = []
  137. const seen = new Set()
  138. for (const s of services) {
  139. const fileBase = `${s.serviceKey}_client`
  140. const className = `${s.serviceName.replace(/Service$/, "")}Client`
  141. const importKey = `${fileBase}:${className}`
  142. if (!seen.has(importKey)) {
  143. importLines.push(`from .services.${fileBase} import ${className}`)
  144. seen.add(importKey)
  145. }
  146. }
  147. // Build wrapper initializations on connect (like Go New<Service>Client)
  148. const initLines = services.map((s) => {
  149. const shortName = s.serviceName.replace(/Service$/, "") // Task
  150. const className = `${shortName}Client`
  151. return ` self.${shortName} = ${className}(self._conn.channel)`
  152. })
  153. // Build attribute resets on disconnect
  154. const nilLines = services.map((s) => {
  155. const shortName = s.serviceName.replace(/Service$/, "")
  156. return ` self.${shortName} = None`
  157. })
  158. const content = `# AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
  159. # Generated by scripts/build-python-proto.mjs
  160. from typing import Optional
  161. import grpc
  162. from .connection import ConnectionManager
  163. ${importLines.join("\n")}
  164. class ClineClient:
  165. """
  166. Unified Python client analogous to src/generated/grpc-go/client/ClineClient.
  167. Usage:
  168. client = ClineClient("localhost:17611")
  169. client.connect()
  170. # Call wrappers, e.g.: client.Task.SomeRpc(...)
  171. client.disconnect()
  172. """
  173. def __init__(self, address: str, timeout: float = 30.0):
  174. self._conn = ConnectionManager(address, timeout=timeout)
  175. self._connected = False
  176. ${services.map((s) => ` self.${s.serviceName.replace(/Service$/, "")}: Optional[object] = None`).join("\n")}
  177. def connect(self) -> None:
  178. if self._connected:
  179. return
  180. self._conn.connect()
  181. ${initLines.join("\n")}
  182. self._connected = True
  183. def disconnect(self) -> None:
  184. if not self._connected:
  185. return
  186. self._conn.disconnect()
  187. ${nilLines.join("\n")}
  188. self._connected = False
  189. def is_connected(self) -> bool:
  190. return self._connected
  191. @property
  192. def channel(self) -> Optional[grpc.Channel]:
  193. return self._conn.channel
  194. `
  195. const clientDir = outDir
  196. await fs.mkdir(clientDir, { recursive: true })
  197. await fs.writeFile(path.join(clientDir, "cline_client.py"), content)
  198. }
  199. async function generatePythonClient(protoDir, pyOutDir, clientDir, protoFiles) {
  200. // Ensure package structure for client
  201. await fs.mkdir(clientDir, { recursive: true })
  202. await ensureInitPy(pyOutDir)
  203. await ensureInitPy(clientDir)
  204. const services = await parseServicesWithFiles(protoDir, protoFiles)
  205. // connection.py
  206. await generateConnectionPy(clientDir)
  207. // services/ per-service wrappers (mirror Go client/services)
  208. const servicesDir = path.join(clientDir, "services")
  209. await fs.mkdir(servicesDir, { recursive: true })
  210. await ensureInitPy(servicesDir)
  211. await generateServiceClientsPy(servicesDir, services)
  212. // cline_client.py (unified that composes service wrappers)
  213. await generateClineClientPy(clientDir, services)
  214. }
  215. async function generateServiceClientsPy(outDir, services) {
  216. await fs.mkdir(outDir, { recursive: true })
  217. await ensureInitPy(outDir)
  218. for (const s of services) {
  219. const shortName = s.serviceName.replace(/Service$/, "") // Task
  220. const className = `${shortName}Client`
  221. const fileName = `${s.serviceKey}_client.py`
  222. const aliasPb2 = `${s.protoPackage}_${s.moduleBase}_pb2`
  223. const aliasGrpc = `${s.protoPackage}_${s.moduleBase}_pb2_grpc`
  224. const methodLines = s.methods
  225. .map((m) => {
  226. const reqTypeName = m.requestType.split(".").pop()
  227. const respTypeName = m.responseType.split(".").pop()
  228. if (m.isResponseStreaming) {
  229. return `
  230. def ${m.name}(self, req):
  231. """
  232. Server-streaming RPC.
  233. :param req: ${aliasPb2}.${reqTypeName}
  234. :return: iterator of ${aliasPb2}.${respTypeName}
  235. """
  236. return self._stub.${m.name}(req)`
  237. } else {
  238. return `
  239. def ${m.name}(self, req):
  240. """
  241. Unary RPC.
  242. :param req: ${aliasPb2}.${reqTypeName}
  243. :return: ${aliasPb2}.${respTypeName}
  244. """
  245. return self._stub.${m.name}(req)`
  246. }
  247. })
  248. .join("\n")
  249. const content = `# AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
  250. # Generated by scripts/build-python-proto.mjs
  251. import grpc
  252. from ${s.protoPackage} import ${s.moduleBase}_pb2 as ${aliasPb2}
  253. from ${s.protoPackage} import ${s.moduleBase}_pb2_grpc as ${aliasGrpc}
  254. class ${className}:
  255. def __init__(self, channel: grpc.Channel):
  256. self._stub = ${aliasGrpc}.${s.serviceName}Stub(channel)
  257. ${methodLines}
  258. `
  259. await fs.writeFile(path.join(outDir, fileName), content)
  260. }
  261. }
  262. async function generatePyproject(outDir) {
  263. const content = `[build-system]
  264. requires = ["setuptools>=68", "wheel"]
  265. build-backend = "setuptools.build_meta"
  266. [project]
  267. name = "cline-grpc-python"
  268. version = "0.1.0"
  269. description = "Generated Python gRPC stubs and client wrappers for Cline protos"
  270. license = { text: "Apache-2.0" }
  271. requires-python = ">=3.9"
  272. dependencies = [
  273. "grpcio>=1.56.0",
  274. "protobuf>=4.21.0"
  275. ]
  276. [tool.setuptools.packages.find]
  277. where = ["."]
  278. `
  279. await fs.writeFile(path.join(outDir, "pyproject.toml"), content)
  280. }
  281. async function main() {
  282. console.log(chalk.cyan("Starting Python protobuf code generation..."))
  283. // Verify proto dir exists
  284. try {
  285. const stat = await fs.stat(PROTO_DIR)
  286. if (!stat.isDirectory()) {
  287. console.error(chalk.red(`Proto directory is not a folder: ${PROTO_DIR}`))
  288. process.exit(1)
  289. }
  290. } catch {
  291. console.error(chalk.red(`Proto directory not found: ${PROTO_DIR}`))
  292. process.exit(1)
  293. }
  294. // Resolve Python
  295. const python = resolvePython()
  296. if (!python) {
  297. console.error(
  298. chalk.red("Python not found on PATH. Please install Python 3 and ensure it is available (python3 or python)."),
  299. )
  300. process.exit(1)
  301. }
  302. console.log(chalk.green(`✓ Using Python executable: ${python}`))
  303. // Check grpcio-tools
  304. if (!checkGrpcTools(python)) {
  305. console.error(chalk.red("Missing dependency: grpcio-tools"))
  306. console.log(chalk.yellow("Install with:"))
  307. console.log(chalk.yellow(` ${python} -m pip install grpcio-tools --user --break-system-packages`))
  308. process.exit(1)
  309. }
  310. console.log(chalk.green("✓ grpcio-tools available"))
  311. // Discover proto files
  312. const protoFiles = await globby("**/*.proto", { cwd: PROTO_DIR })
  313. if (!protoFiles.length) {
  314. console.error(chalk.red("No .proto files found under ./proto"))
  315. process.exit(1)
  316. }
  317. console.log(chalk.cyan(`Found ${protoFiles.length} proto files`))
  318. // Ensure output directory
  319. await ensureDir(PY_OUT_DIR)
  320. // Build and run protoc command via grpc_tools
  321. const quoted = (s) => `"${s}"`
  322. const pythonCmd = quoted(python)
  323. const cmd =
  324. `${pythonCmd} -m grpc_tools.protoc ` +
  325. `-I ${quoted(PROTO_DIR)} ` +
  326. `--python_out=${quoted(PY_OUT_DIR)} ` +
  327. `--grpc_python_out=${quoted(PY_OUT_DIR)} ` +
  328. protoFiles.map((f) => quoted(f)).join(" ")
  329. try {
  330. console.log(chalk.cyan(`Generating Python code into ${PY_OUT_DIR}...`))
  331. execSync(cmd, { cwd: ROOT_DIR, stdio: "inherit", env: process.env })
  332. } catch (error) {
  333. console.error(chalk.red("Error generating Python code:"), error?.message || error)
  334. process.exit(1)
  335. }
  336. // Ensure package structure (__init__.py) for imports
  337. await ensureInitPy(PY_OUT_DIR)
  338. try {
  339. const clineDir = path.join(PY_OUT_DIR, "cline")
  340. const hostDir = path.join(PY_OUT_DIR, "host")
  341. // These may or may not exist depending on which protos are present
  342. await fs
  343. .stat(clineDir)
  344. .then(() => ensureInitPy(clineDir))
  345. .catch(() => {})
  346. await fs
  347. .stat(hostDir)
  348. .then(() => ensureInitPy(hostDir))
  349. .catch(() => {})
  350. } catch {
  351. // ignore
  352. }
  353. // Generate Python client structure analogous to src/generated/grpc-go/client
  354. await generatePythonClient(PROTO_DIR, PY_OUT_DIR, PY_CLIENT_DIR, protoFiles)
  355. // Generate a minimal pyproject.toml in the generated output so it can be pip-installed if desired
  356. await generatePyproject(PY_OUT_DIR)
  357. console.log(chalk.green("✓ Python protobuf and client code generation completed successfully!"))
  358. console.log(chalk.cyan(`Output directory: ${PY_OUT_DIR}`))
  359. console.log(chalk.cyan(`Client directory: ${PY_CLIENT_DIR}`))
  360. console.log(chalk.cyan(`PyProject: ${path.join(PY_OUT_DIR, "pyproject.toml")}`))
  361. console.log(chalk.gray("Note: To import, add the output dir to your PYTHONPATH or pip install -e src/generated/grpc-python"))
  362. }
  363. if (import.meta.url === `file://${process.argv[1]}`) {
  364. main().catch((err) => {
  365. console.error(chalk.red("Unexpected error in build-python-proto.mjs:"), err)
  366. process.exit(1)
  367. })
  368. }