Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
- Fixes `spawn activate.bat ENOENT` error on Windows when initializing Python functions. (#10608)
- Fixed an issue where Cloud Run rewrites in the Hosting emulator would always hit the live Cloud Run API instead of routing to the local functions emulator. (#10588)
- Removed temporary warning directing Dart functions users to Cloud Console, as Firebase Console now supports Dart functions. (#10584)
- Updated the Firebase Data Connect local toolkit to v3.4.11, which includes the following changes:
Expand Down
6 changes: 3 additions & 3 deletions src/functions/python.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as path from "path";
import * as spawn from "cross-spawn";
import { spawn } from "cross-spawn";
import * as cp from "child_process";
import { logger } from "../logger";
import { IS_WINDOWS } from "../utils";
Expand All @@ -17,7 +17,7 @@ export function virtualEnvCmd(cwd: string, venvDir: string): { command: string;
const venvActivate = `"${path.join(cwd, venvDir, ...activateScriptPath)}"`;
return {
command: IS_WINDOWS ? venvActivate : ".",
args: [IS_WINDOWS ? "" : venvActivate],
args: IS_WINDOWS ? [] : [venvActivate],
};
}

Expand All @@ -38,7 +38,7 @@ export function runWithVirtualEnv(
return spawn(command, args, {
shell: true,
cwd,
stdio: [/* stdin= */ "pipe", /* stdout= */ "pipe", /* stderr= */ "pipe", "pipe"],
stdio: "pipe",
...spawnOpts,
// Linting disabled since internal types expect NODE_ENV which does not apply to Python runtimes.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
Expand Down
172 changes: 172 additions & 0 deletions src/init/features/functions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import { Options } from "../../options";
import { RC } from "../../rc";
import * as experiments from "../../experiments";
import * as spawn from "cross-spawn";
import * as initSpawn from "../spawn";

const TEST_SOURCE_DEFAULT = "functions";
const TEST_CODEBASE_DEFAULT = "default";
Expand Down Expand Up @@ -89,7 +91,7 @@
predeploy: ['npm --prefix "$RESOURCE_DIR" run lint'],
disallowLegacyRuntimeConfig: true,
});
expect(askWriteProjectFileStub.getCalls().map((call) => call.args[0])).to.have.members([

Check warning on line 94 in src/init/features/functions.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe return of an `any` typed value
`${TEST_SOURCE_DEFAULT}/package.json`,
`${TEST_SOURCE_DEFAULT}/.eslintrc.js`,
`${TEST_SOURCE_DEFAULT}/index.js`,
Expand Down Expand Up @@ -120,7 +122,7 @@
],
disallowLegacyRuntimeConfig: true,
});
expect(askWriteProjectFileStub.getCalls().map((call) => call.args[0])).to.have.members([

Check warning on line 125 in src/init/features/functions.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe return of an `any` typed value
`${TEST_SOURCE_DEFAULT}/package.json`,
`${TEST_SOURCE_DEFAULT}/.eslintrc.js`,
`${TEST_SOURCE_DEFAULT}/tsconfig.dev.json`,
Expand All @@ -130,6 +132,176 @@
]);
});

describe("python project", () => {
let spawnStub: sinon.SinonStub;
let wrapSpawnStub: sinon.SinonStub;

beforeEach(() => {
spawnStub = sandbox.stub(spawn, "spawn");
wrapSpawnStub = sandbox.stub(initSpawn, "wrapSpawn");
});

it("creates a new python codebase with the correct configuration", async () => {
const config = new Config("{}", { projectDir: "test", cwd: "test" });
const setup = { config: { functions: [] }, rcfile: {} };
prompt.select.onFirstCall().resolves("python");
// do not install dependencies
prompt.confirm.onFirstCall().resolves(false);
askWriteProjectFileStub = sandbox.stub(config, "askWriteProjectFile");
askWriteProjectFileStub.resolves();
wrapSpawnStub.resolves();

await askQuestions(setup, config, options);
await actuate(setup, config);

expect(setup.config.functions[0]).to.deep.equal({
source: TEST_SOURCE_DEFAULT,
codebase: TEST_CODEBASE_DEFAULT,
ignore: ["venv", ".git", "firebase-debug.log", "firebase-debug.*.log", "*.local"],
runtime: "python314",
disallowLegacyRuntimeConfig: true,
});
expect(askWriteProjectFileStub.getCalls().map((call) => call.args[0])).to.have.members([

Check warning on line 164 in src/init/features/functions.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe return of an `any` typed value
`${TEST_SOURCE_DEFAULT}/requirements.txt`,
`${TEST_SOURCE_DEFAULT}/.gitignore`,
`${TEST_SOURCE_DEFAULT}/main.py`,
]);
expect(wrapSpawnStub.callCount).to.equal(1);
expect(spawnStub.callCount).to.equal(0);
});

it("throws FirebaseError if venv creation fails", async () => {
const config = new Config("{}", { projectDir: "test", cwd: "test" });
const setup = { config: { functions: [] }, rcfile: {} };
prompt.select.onFirstCall().resolves("python");
prompt.confirm.onFirstCall().resolves(false);
askWriteProjectFileStub = sandbox.stub(config, "askWriteProjectFile");
askWriteProjectFileStub.resolves();
wrapSpawnStub.rejects(new Error("Failed to spawn"));

await askQuestions(setup, config, options);
let err: Error | null = null;
try {
await actuate(setup, config);
} catch (e: any) {

Check warning on line 186 in src/init/features/functions.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type
err = e;

Check warning on line 187 in src/init/features/functions.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
}
expect(err).to.not.be.null;
expect(err!.message).to.contain("Failed to create virtual environment");

Check warning on line 190 in src/init/features/functions.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Forbidden non-null assertion
});

it("installs dependencies successfully if user confirms", async () => {
const config = new Config("{}", { projectDir: "test", cwd: "test" });
const setup = { config: { functions: [] }, rcfile: {} };
prompt.select.onFirstCall().resolves("python");
prompt.confirm.onFirstCall().resolves(true); // install dependencies
askWriteProjectFileStub = sandbox.stub(config, "askWriteProjectFile");
askWriteProjectFileStub.resolves();
wrapSpawnStub.resolves();

const successProcess = {
on: (event: string, callback: (code: number | null) => void) => {
if (event === "exit") {
setTimeout(() => callback(0), 0);
}
},
};
spawnStub.returns(successProcess);

await askQuestions(setup, config, options);
await actuate(setup, config);

expect(wrapSpawnStub.callCount).to.equal(1);
expect(spawnStub.callCount).to.equal(2); // pip upgrade, pip install
});

it("throws FirebaseError if pip upgrade fails", async () => {
const config = new Config("{}", { projectDir: "test", cwd: "test" });
const setup = { config: { functions: [] }, rcfile: {} };
prompt.select.onFirstCall().resolves("python");
prompt.confirm.onFirstCall().resolves(true); // install dependencies
askWriteProjectFileStub = sandbox.stub(config, "askWriteProjectFile");
askWriteProjectFileStub.resolves();
wrapSpawnStub.resolves();

const failProcess = {
on: (event: string, callback: (code: number | null) => void) => {
if (event === "exit") {
setTimeout(() => callback(1), 0);
}
},
};
spawnStub.returns(failProcess); // pip upgrade fails

await askQuestions(setup, config, options);
let err: Error | null = null;
try {
await actuate(setup, config);
} catch (e: any) {

Check warning on line 240 in src/init/features/functions.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type
err = e;

Check warning on line 241 in src/init/features/functions.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
}
expect(err).to.not.be.null;
expect(err!.message).to.contain("Failed to upgrade pip inside virtual environment");

Check warning on line 244 in src/init/features/functions.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Forbidden non-null assertion
});

it("throws FirebaseError if dependency installation fails", async () => {
const config = new Config("{}", { projectDir: "test", cwd: "test" });
const setup = { config: { functions: [] }, rcfile: {} };
prompt.select.onFirstCall().resolves("python");
prompt.confirm.onFirstCall().resolves(true); // install dependencies
askWriteProjectFileStub = sandbox.stub(config, "askWriteProjectFile");
askWriteProjectFileStub.resolves();
wrapSpawnStub.resolves();

const successProcess = {
on: (event: string, callback: (code: number | null) => void) => {
if (event === "exit") {
setTimeout(() => callback(0), 0);
}
},
};
const failProcess = {
on: (event: string, callback: (code: number | null) => void) => {
if (event === "exit") {
setTimeout(() => callback(1), 0);
}
},
};
spawnStub.onCall(0).returns(successProcess); // pip upgrade succeeds
spawnStub.onCall(1).returns(failProcess); // pip install fails

await askQuestions(setup, config, options);
let err: Error | null = null;
try {
await actuate(setup, config);
} catch (e: any) {

Check warning on line 277 in src/init/features/functions.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type
err = e;
}
expect(err).to.not.be.null;
expect(err!.message).to.contain("Failed to install dependencies");
});

it("throws FirebaseError if venv creation encounters an error event", async () => {
const config = new Config("{}", { projectDir: "test", cwd: "test" });
const setup = { config: { functions: [] }, rcfile: {} };
prompt.select.onFirstCall().resolves("python");
prompt.confirm.onFirstCall().resolves(false);
askWriteProjectFileStub = sandbox.stub(config, "askWriteProjectFile");
askWriteProjectFileStub.resolves();
wrapSpawnStub.rejects(new Error("Spawn error"));

await askQuestions(setup, config, options);
let err: Error | null = null;
try {
await actuate(setup, config);
} catch (e: any) {
err = e;
}
expect(err).to.not.be.null;
expect(err!.message).to.contain("Failed to create virtual environment");
});
});

it("does not show Dart as an option when experiments are disabled", async () => {
const wasEnabled = experiments.isEnabled("dartfunctions");
experiments.setEnabled("dartfunctions", false);
Expand Down
95 changes: 63 additions & 32 deletions src/init/features/functions/python.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,77 @@
import * as spawn from "cross-spawn";
import { ChildProcess } from "child_process";
import { wrapSpawn } from "../../spawn";

import { Config } from "../../../config";
import { getPythonBinary } from "../../../deploy/functions/runtimes/python";
import { runWithVirtualEnv } from "../../../functions/python";
import { confirm } from "../../../prompt";
import { latest } from "../../../deploy/functions/runtimes/supported";
import { readTemplateSync } from "../../../templates";
import { FirebaseError } from "../../../error";

const MAIN_TEMPLATE = readTemplateSync("init/functions/python/main.py");
const REQUIREMENTS_TEMPLATE = readTemplateSync("init/functions/python/requirements.txt");
const GITIGNORE_TEMPLATE = readTemplateSync("init/functions/python/_gitignore");

/**
* Helper to wait for a child process to exit.
*/
function waitForProcess(p: ChildProcess): Promise<number | null> {
return new Promise((resolve) => {
p.on("exit", resolve);
p.on("error", () => resolve(-1));
});
}

/**
* Helper to spawn a process and create a python virtual environment.
*/
async function createVirtualEnv(pythonBinary: string, cwd: string): Promise<void> {
try {
await wrapSpawn(pythonBinary, ["-m", "venv", "venv"], cwd);
} catch (err) {
throw new FirebaseError(
`Failed to create virtual environment. Please make sure Python is installed and in your PATH.`,
);
}
}

/**
* Helper to upgrade pip and install requirements.txt dependencies inside the virtual environment.
*/
async function installDependencies(pythonBinary: string, cwd: string): Promise<void> {
// Update pip to support dependencies like pyyaml.
// Note: We execute pip using pythonBinary `-m pip` instead of calling `pip3` directly.
// Calling `pip3` directly on Windows can cause OS file locking failures since the executable attempts to overwrite itself.
const upgradeProcess = runWithVirtualEnv(
[pythonBinary, "-m", "pip", "install", "--upgrade", "pip"],
cwd,
{},
{ stdio: "inherit" },
);
const upgradeExitCode = await waitForProcess(upgradeProcess);
if (upgradeExitCode !== 0) {
throw new FirebaseError("Failed to upgrade pip inside virtual environment.");
}

const installProcess = runWithVirtualEnv(
[pythonBinary, "-m", "pip", "install", "-r", "requirements.txt"],
cwd,
{},
{ stdio: "inherit" },
);
const installExitCode = await waitForProcess(installProcess);
if (installExitCode !== 0) {
throw new FirebaseError("Failed to install dependencies.");
}
}

/**
* Create a Python Firebase Functions project.
*/
export async function setup(setup: any, config: Config): Promise<void> {
const sourceDir = config.path(setup.functions.source);

await config.askWriteProjectFile(
`${setup.functions.source}/requirements.txt`,
REQUIREMENTS_TEMPLATE,
Expand All @@ -27,42 +84,16 @@ export async function setup(setup: any, config: Config): Promise<void> {
// Add python specific ignores to config.
config.set("functions.ignore", ["venv", "__pycache__"]);

// Setup VENV.
const venvProcess = spawn(getPythonBinary(latest("python")), ["-m", "venv", "venv"], {
shell: true,
cwd: config.path(setup.functions.source),
stdio: [/* stdin= */ "pipe", /* stdout= */ "pipe", /* stderr= */ "pipe", "pipe"],
});
await new Promise((resolve, reject) => {
venvProcess.on("exit", resolve);
venvProcess.on("error", reject);
});
// We resolve the version-specific Python binary (e.g. python3.10) to ensure we execute
// the correct version of Python chosen for the functions codebase.
const pythonBinary = getPythonBinary(latest("python"));
await createVirtualEnv(pythonBinary, sourceDir);

const install = await confirm({
message: "Do you want to install dependencies now?",
default: true,
});
if (install) {
// Update pip to support dependencies like pyyaml.
const upgradeProcess = runWithVirtualEnv(
["pip3", "install", "--upgrade", "pip"],
config.path(setup.functions.source),
{},
{ stdio: ["inherit", "inherit", "inherit"] },
);
await new Promise((resolve, reject) => {
upgradeProcess.on("exit", resolve);
upgradeProcess.on("error", reject);
});
const installProcess = runWithVirtualEnv(
[getPythonBinary(latest("python")), "-m", "pip", "install", "-r", "requirements.txt"],
config.path(setup.functions.source),
{},
{ stdio: ["inherit", "inherit", "inherit"] },
);
await new Promise((resolve, reject) => {
installProcess.on("exit", resolve);
installProcess.on("error", reject);
});
await installDependencies(pythonBinary, sourceDir);
}
}
Loading