Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add python support for Functions Emulator. #5423

Merged
merged 18 commits into from
Jan 25, 2023
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 scripts/emulator-tests/functionsEmulator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const TEST_BACKEND = {
secretEnv: [],
codebase: "default",
bin: process.execPath,
runtime: "nodejs14",
// NOTE: Use the following node bin path if you want to run test cases directly from your IDE.
// bin: path.join(MODULE_ROOT, "node_modules/.bin/ts-node"),
};
Expand Down
2 changes: 1 addition & 1 deletion src/deploy/functions/runtimes/discovery/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export async function detectFromPort(

while (true) {
try {
res = await Promise.race([fetch(`http://localhost:${port}/__/functions.yaml`), timedOut]);
res = await Promise.race([fetch(`http://127.0.0.1:${port}/__/functions.yaml`), timedOut]);
break;
} catch (err: any) {
// Allow us to wait until the server is listening.
Expand Down
7 changes: 5 additions & 2 deletions src/deploy/functions/runtimes/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as backend from "../backend";
import * as build from "../build";
import * as node from "./node";
import * as python from "./python";
import * as validate from "../validate";
import { FirebaseError } from "../../../error";

Expand All @@ -9,7 +10,7 @@ const RUNTIMES: string[] = ["nodejs10", "nodejs12", "nodejs14", "nodejs16", "nod
// Experimental runtimes are part of the Runtime type, but are in a
// different list to help guard against some day accidentally iterating over
// and printing a hidden runtime to the user.
const EXPERIMENTAL_RUNTIMES: string[] = [];
const EXPERIMENTAL_RUNTIMES: string[] = ["python310", "python311"];
export type Runtime = typeof RUNTIMES[number] | typeof EXPERIMENTAL_RUNTIMES[number];

/** Runtimes that can be found in existing backends but not used for new functions. */
Expand All @@ -34,6 +35,8 @@ const MESSAGE_FRIENDLY_RUNTIMES: Record<Runtime | DeprecatedRuntime, string> = {
nodejs14: "Node.js 14",
nodejs16: "Node.js 16",
nodejs18: "Node.js 18",
python310: "Python 3.10",
python311: "Python 3.11 (Preview)",
};

/**
Expand Down Expand Up @@ -113,7 +116,7 @@ export interface DelegateContext {
}

type Factory = (context: DelegateContext) => Promise<RuntimeDelegate | undefined>;
const factories: Factory[] = [node.tryCreateDelegate];
const factories: Factory[] = [node.tryCreateDelegate, python.tryCreateDelegate];

/**
*
Expand Down
152 changes: 152 additions & 0 deletions src/deploy/functions/runtimes/python/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import * as fs from "fs";
import * as path from "path";
import fetch from "node-fetch";
import { promisify } from "util";

import * as portfinder from "portfinder";

import * as runtimes from "..";
import * as backend from "../../backend";
import * as discovery from "../discovery";
import { logger } from "../../../../logger";
import { runWithVirtualEnv } from "../../../../functions/python";
import { FirebaseError } from "../../../../error";
import { Build } from "../../build";

const LATEST_VERSION: runtimes.Runtime = "python310";

/**
* Create a runtime delegate for the Python runtime, if applicable.
*
* @param context runtimes.DelegateContext
* @return Delegate Python runtime delegate
*/
export async function tryCreateDelegate(
context: runtimes.DelegateContext
): Promise<Delegate | undefined> {
const requirementsTextPath = path.join(context.sourceDir, "requirements.txt");

if (!(await promisify(fs.exists)(requirementsTextPath))) {
logger.debug("Customer code is not Python code.");
return;
}
const runtime = context.runtime ? context.runtime : LATEST_VERSION;
if (!runtimes.isValidRuntime(runtime)) {
throw new FirebaseError(`Runtime ${runtime} is not a valid Python runtime`);
}
return Promise.resolve(new Delegate(context.projectId, context.sourceDir, runtime));
}

export class Delegate implements runtimes.RuntimeDelegate {
public readonly name = "python";
constructor(
private readonly projectId: string,
private readonly sourceDir: string,
public readonly runtime: runtimes.Runtime
) {}

private _bin = "";
private _modulesDir = "";

get bin(): string {
if (this._bin === "") {
this._bin = this.getPythonBinary();
}
return this._bin;
}

async modulesDir(): Promise<string> {
if (!this._modulesDir) {
const child = runWithVirtualEnv(
[
this.bin,
"-c",
'"import firebase_functions; import os; print(os.path.dirname(firebase_functions.__file__))"',
],
this.sourceDir,
{}
);
let out = "";
child.stdout?.on("data", (chunk: Buffer) => {
const chunkString = chunk.toString();
out = out + chunkString;
logger.debug(`stdout: ${chunkString}`);
});
await new Promise((resolve, reject) => {
child.on("exit", resolve);
child.on("error", reject);
});
this._modulesDir = out.trim();
}
return this._modulesDir;
}

getPythonBinary(): string {
if (process.platform === "win32") {
// There is no easy way to get specific version of python executable in Windows.
return "python.exe";
}
if (this.runtime === "python310") {
return "python3.10";
} else if (this.runtime === "python311") {
return "python3.11";
}
return "python";
}

validate(): Promise<void> {
// TODO: make sure firebase-functions is included as a dep
return Promise.resolve();
}

watch(): Promise<() => Promise<void>> {
return Promise.resolve(() => Promise.resolve());
}

async build(): Promise<void> {
return Promise.resolve();
}

async serveAdmin(port: number, envs: backend.EnvironmentVariables): Promise<() => Promise<void>> {
const modulesDir = await this.modulesDir();
const envWithAdminPort = {
...envs,
ADMIN_PORT: port.toString(),
};
const args = [this.bin, path.join(modulesDir, "private", "serving.py")];
logger.debug(
`Running admin server with args: ${JSON.stringify(args)} and env: ${JSON.stringify(
envWithAdminPort
)} in ${this.sourceDir}`
);
const childProcess = runWithVirtualEnv(args, this.sourceDir, envWithAdminPort);
return Promise.resolve(async () => {
await fetch(`http://127.0.0.1:${port}/__/quitquitquit`);
const quitTimeout = setTimeout(() => {
if (!childProcess.killed) {
childProcess.kill("SIGKILL");
}
}, 10_000);
clearTimeout(quitTimeout);
});
}

async discoverBuild(
_configValues: backend.RuntimeConfigValues,
envs: backend.EnvironmentVariables
): Promise<Build> {
let discovered = await discovery.detectFromYaml(this.sourceDir, this.projectId, this.runtime);
if (!discovered) {
const adminPort = await portfinder.getPortPromise({
port: 8081,
});
const killProcess = await this.serveAdmin(adminPort, envs);
try {
discovered = await discovery.detectFromPort(adminPort, this.projectId, this.runtime);
} finally {
await killProcess();
}
}
return discovered;
}
}
Loading