diff --git a/packages/cli/src/tts/python.test.ts b/packages/cli/src/tts/python.test.ts index b53d0396a5..bfb2ba7a90 100644 --- a/packages/cli/src/tts/python.test.ts +++ b/packages/cli/src/tts/python.test.ts @@ -15,7 +15,13 @@ vi.mock("node:child_process", async (importOriginal) => { return { ...actual, execFileSync: execFileSyncMock }; }); -const { findPython, describeRejectedPythonOverride } = await import("./python.js"); +const { + findPython, + describeRejectedPythonOverride, + hasPythonPackage, + hasPythonModules, + workingPythonEnv, +} = await import("./python.js"); describe("findPython / describeRejectedPythonOverride", () => { beforeEach(() => { @@ -76,3 +82,58 @@ describe("findPython / describeRejectedPythonOverride", () => { expect(execFileSyncMock).toHaveBeenCalledTimes(1); }); }); + +describe("PYTHONPATH when probing for Python packages", () => { + const PYTHON = "/opt/venv/bin/python"; + + // The import works only for the environment `works` accepts, like a real interpreter would. + function importsWhen(works: (env: NodeJS.ProcessEnv) => boolean) { + execFileSyncMock.mockReset(); + execFileSyncMock.mockImplementation((_cmd: string, args: string[], options: any) => { + if (args[0] === "-c" && !works(options.env)) throw new Error("ModuleNotFoundError"); + return "Python 3.11.0"; + }); + } + + beforeEach(() => { + process.env.HYPERFRAMES_PYTHON = PYTHON; + }); + + afterEach(() => { + delete process.env.PYTHONPATH; + delete process.env.HYPERFRAMES_PYTHON; + }); + + it("keeps a PYTHONPATH the user set on purpose when the package needs it", () => { + process.env.PYTHONPATH = "/deliberate/site-packages"; + importsWhen((env) => env.PYTHONPATH === "/deliberate/site-packages"); + + expect(hasPythonPackage(PYTHON, "kokoro_onnx")).toBe(true); + expect(hasPythonModules(["kokoro_onnx"])).toBe(true); + expect(workingPythonEnv(PYTHON, ["kokoro_onnx"], { A: "1" })).toMatchObject({ + PYTHONPATH: "/deliberate/site-packages", + A: "1", + }); + }); + + it("drops a foreign PYTHONPATH that stops the package from importing", () => { + process.env.PYTHONPATH = "/other/python/site-packages"; + importsWhen((env) => env.PYTHONPATH === undefined); + + expect(hasPythonPackage(PYTHON, "kokoro_onnx")).toBe(true); + expect(hasPythonModules(["kokoro_onnx"])).toBe(true); + const env = workingPythonEnv(PYTHON, ["kokoro_onnx"], { ORT_DISABLE_TELEMETRY: "1" }); + expect(env).not.toHaveProperty("PYTHONPATH"); + expect(env).toMatchObject({ PATH: process.env.PATH, ORT_DISABLE_TELEMETRY: "1" }); + }); + + it("tries the inherited environment first and reports a package neither finds", () => { + process.env.PYTHONPATH = "/other/python/site-packages"; + importsWhen(() => false); + + expect(hasPythonPackage(PYTHON, "kokoro_onnx")).toBe(false); + expect(workingPythonEnv(PYTHON, ["kokoro_onnx"])).toBeNull(); + const [first] = execFileSyncMock.mock.calls.filter(([, args]) => args[0] === "-c"); + expect(first?.[2].env.PYTHONPATH).toBe("/other/python/site-packages"); + }); +}); diff --git a/packages/cli/src/tts/python.ts b/packages/cli/src/tts/python.ts index 3e7ec13095..563578af13 100644 --- a/packages/cli/src/tts/python.ts +++ b/packages/cli/src/tts/python.ts @@ -77,19 +77,38 @@ export function describeRejectedPythonOverride(): string | null { return `HYPERFRAMES_PYTHON="${override}" was rejected: ${validation.reason}`; } -/** True if `import ` succeeds — actually executes the module. */ -export function hasPythonPackage(python: string, pkg: string): boolean { +function withoutPythonPath(): NodeJS.ProcessEnv { + const { PYTHONPATH: _foreign, ...env } = process.env; + return env; +} + +function runs(python: string, code: string, env: NodeJS.ProcessEnv): boolean { try { - execFileSync(python, ["-c", `import ${pkg}`], { - stdio: ["pipe", "pipe", "pipe"], - timeout: 10_000, - }); + execFileSync(python, ["-c", code], { stdio: ["pipe", "pipe", "pipe"], timeout: 10_000, env }); return true; } catch { return false; } } +function environmentThatRuns(python: string, code: string): NodeJS.ProcessEnv | null { + return [process.env, withoutPythonPath()].find((env) => runs(python, code, env)) ?? null; +} + +export function workingPythonEnv( + python: string, + modules: string[], + extra: NodeJS.ProcessEnv = {}, +): NodeJS.ProcessEnv | null { + const env = environmentThatRuns(python, `import ${modules.join(", ")}`); + return env && { ...env, ...extra }; +} + +/** True if `import ` succeeds — actually executes the module. */ +export function hasPythonPackage(python: string, pkg: string): boolean { + return environmentThatRuns(python, `import ${pkg}`) !== null; +} + /** * True if every module is installed, checked via `importlib.util.find_spec` * so heavy packages (torch) are never imported — fast enough for a preflight. @@ -100,13 +119,5 @@ export function hasPythonModules(modules: string[]): boolean { if (!python) return false; const list = JSON.stringify(modules); const probe = `import importlib.util,sys; sys.exit(0 if all(importlib.util.find_spec(m) for m in ${list}) else 1)`; - try { - execFileSync(python, ["-c", probe], { - stdio: ["pipe", "pipe", "pipe"], - timeout: 10_000, - }); - return true; - } catch { - return false; - } + return environmentThatRuns(python, probe) !== null; } diff --git a/packages/cli/src/tts/synthesize.cache.test.ts b/packages/cli/src/tts/synthesize.cache.test.ts index fa2199ccea..ebf44e98c4 100644 --- a/packages/cli/src/tts/synthesize.cache.test.ts +++ b/packages/cli/src/tts/synthesize.cache.test.ts @@ -14,7 +14,8 @@ vi.mock("node:fs", async (importOriginal) => { return { ...original, existsSync: vi.fn(original.existsSync) }; }); vi.mock("node:child_process", () => ({ execFileSync: vi.fn() })); -vi.mock("./python.js", () => ({ +vi.mock("./python.js", async (importOriginal) => ({ + ...(await importOriginal()), findPython: () => "test-python", hasPythonPackage: () => true, })); @@ -53,6 +54,28 @@ describe("synthesis script cache", () => { fs.rmSync(paths.home, { recursive: true, force: true }); }); + it("runs the synthesis process in the environment the packages imported under", async () => { + vi.mocked(execFileSync).mockImplementation((_cmd, args, options) => { + const importing = String(args?.[0]) === "-c"; + const env = (options as { env?: NodeJS.ProcessEnv }).env; + if (importing && env?.PYTHONPATH !== undefined) throw new Error("ModuleNotFoundError"); + fs.writeFileSync(outputPath, "stub audio"); + return JSON.stringify({ + outputPath, + sampleRate: 24000, + durationSeconds: 1, + langApplied: true, + }); + }); + vi.stubEnv("PYTHONPATH", "/other/python/site-packages"); + const { synthesize } = await import("./synthesize.js"); + await synthesize("Hello", outputPath); + + const options = vi.mocked(execFileSync).mock.calls.at(-1)?.[2]; + expect(options?.env).not.toHaveProperty("PYTHONPATH"); + vi.unstubAllEnvs(); + }); + it("turns off onnxruntime telemetry for the synthesis process", async () => { const { synthesize } = await import("./synthesize.js"); await synthesize("Hello", outputPath); diff --git a/packages/cli/src/tts/synthesize.ts b/packages/cli/src/tts/synthesize.ts index 8394ad7ae0..4ec225a248 100644 --- a/packages/cli/src/tts/synthesize.ts +++ b/packages/cli/src/tts/synthesize.ts @@ -10,7 +10,7 @@ import { inferLangFromVoiceId, type SupportedLang, } from "./manager.js"; -import { findPython, hasPythonPackage } from "./python.js"; +import { findPython, hasPythonPackage, workingPythonEnv } from "./python.js"; import { writeNewFileSync } from "../utils/writeNewFile.js"; // --------------------------------------------------------------------------- @@ -189,13 +189,15 @@ export async function synthesize( ); } - if (!hasPythonPackage(python, "kokoro_onnx")) { - throw new Error( - "The kokoro-onnx package is not installed. Run: pip install kokoro-onnx soundfile (or point HYPERFRAMES_PYTHON at a venv python that has them)", - ); - } - - if (!hasPythonPackage(python, "soundfile")) { + const pythonEnv = workingPythonEnv(python, ["kokoro_onnx", "soundfile"], { + ORT_DISABLE_TELEMETRY: "1", + }); + if (!pythonEnv) { + if (!hasPythonPackage(python, "kokoro_onnx")) { + throw new Error( + "The kokoro-onnx package is not installed. Run: pip install kokoro-onnx soundfile (or point HYPERFRAMES_PYTHON at a venv python that has them)", + ); + } throw new Error("The soundfile package is not installed. Run: pip install soundfile"); } @@ -222,7 +224,7 @@ export async function synthesize( encoding: "utf-8", timeout: 300_000, stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, ORT_DISABLE_TELEMETRY: "1" }, + env: pythonEnv, }, );