Преглед изворни кода

Feat/nturumel/proto-python (#7090)

* feat(proto-python): add script-generated Python gRPC stubs, Go-like client, docs, and PyPI publish workflow

- add scripts/build-python-proto.mjs

  - invokes python -m grpc_tools.protoc over proto/**/*.proto
  - outputs to src/generated/grpc-python
  - mirrors Go layout under client/: connection.py, cline_client.py, services/_client.py
  - supports PYTHON env override (use a venv interpreter easily)
  - generates src/generated/grpc-python/pyproject.toml so output can be pip installed (pip install -e src/generated/grpc-python)

- package.json

  - add protos-python script to run the generator

- docs

  - add docs/exploring-clines-tools/python-protos.mdx with venv setup, generation steps, and import examples
  - emphasize: everything in src/generated is produced by scripts (do not commit manual edits)

- CI: publish to PyPI only

  - add .github/workflows/publish-grpc-python.yml
  - workflow generates code via script, builds wheel/sdist from src/generated/grpc-python, and uploads to PyPI
  - requires repo secret: PYPI_API_TOKEN (TWINE_USERNAME=__token__)
  - optional version override input for workflow_dispatch

Notes:

- generation strictly produces all content under src/generated/grpc-python (including pyproject.toml)
- default package name in generated pyproject is cline-grpc-python (adjustable in the script if needed)
- recommended usage on macOS: PYTHON=/Users/nturumel/projects/oracle-github/cline/.venv-proto/bin/python npm run protos-python

* Delete .github/workflows/publish-grpc-python.yml

* Delete docs/exploring-clines-tools/python-protos.mdx

* Update tired-banks-show.md

---------

Co-authored-by: Andrei Eternal <[email protected]>
nihar-oracle пре 2 месеци
родитељ
комит
5a3416ff09
3 измењених фајлова са 432 додато и 0 уклоњено
  1. 5 0
      .changeset/tired-banks-show.md
  2. 1 0
      package.json
  3. 426 0
      scripts/build-python-proto.mjs

+ 5 - 0
.changeset/tired-banks-show.md

@@ -0,0 +1,5 @@
+---
+"claude-dev": minor
+---
+
+This PR introduces a Python gRPC codegen flow analogous to the existing Go flow

+ 1 - 0
package.json

@@ -310,6 +310,7 @@
 		"package": "npm run check-types && npm run build:webview && npm run lint && node esbuild.mjs --production",
 		"protos": "node scripts/build-proto.mjs",
 		"protos-go": "node scripts/build-go-proto.mjs",
+		"protos-python": "node scripts/build-python-proto.mjs",
 		"cli-providers": "node scripts/cli-providers.mjs",
 		"download-ripgrep": "node scripts/download-ripgrep.mjs",
 		"postprotos": "biome format src/shared/proto src/core/controller src/hosts/ webview-ui/src/services src/generated --write --no-errors-on-unmatched",

+ 426 - 0
scripts/build-python-proto.mjs

@@ -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)
+	})
+}