Skip to content

Commit 06b8bad

Browse files
authored
Add python support for Functions Emulator. (#5423)
Add support for loading and serving functions written using the Firebase Functions Python SDK (WIP: https://github.com/firebase/firebase-functions-python) This PR is a fork of #4653 extended with support for emulating Python functions. Python Runtime Delegate implementation is unsurprising but does include some additional wrapper code to make sure all commands (e.g. spinning up the admin server) runs within the virtualenv environment. For now, we hardcode virtual environment `venv` directory exists on the developer's laptop, but we'll later add support for specifying arbitrary directory for specifying virtualenv directory via firebase.json configuration. Another note is that each emulated Python function will bind to a Port instead of Unix Domain Socket (UDS) as done when emulating Node.js function. This is because there is no straightfoward, platform-neutral way to bind python webserver to UDS. Finding large number of open port might have a bit more performance penalty and cause bugs due to race condition (similar to #5418) but it seems that we have no other choice atm.
1 parent 225c1d7 commit 06b8bad

File tree

10 files changed

+354
-49
lines changed

10 files changed

+354
-49
lines changed

scripts/emulator-tests/functionsEmulator.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const TEST_BACKEND = {
3838
secretEnv: [],
3939
codebase: "default",
4040
bin: process.execPath,
41+
runtime: "nodejs14",
4142
// NOTE: Use the following node bin path if you want to run test cases directly from your IDE.
4243
// bin: path.join(MODULE_ROOT, "node_modules/.bin/ts-node"),
4344
};

src/deploy/functions/runtimes/discovery/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export async function detectFromPort(
7272

7373
while (true) {
7474
try {
75-
res = await Promise.race([fetch(`http://localhost:${port}/__/functions.yaml`), timedOut]);
75+
res = await Promise.race([fetch(`http://127.0.0.1:${port}/__/functions.yaml`), timedOut]);
7676
break;
7777
} catch (err: any) {
7878
// Allow us to wait until the server is listening.

src/deploy/functions/runtimes/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as backend from "../backend";
22
import * as build from "../build";
33
import * as node from "./node";
4+
import * as python from "./python";
45
import * as validate from "../validate";
56
import { FirebaseError } from "../../../error";
67

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

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

3942
/**
@@ -113,7 +116,7 @@ export interface DelegateContext {
113116
}
114117

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

118121
/**
119122
*
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import * as fs from "fs";
2+
import * as path from "path";
3+
import fetch from "node-fetch";
4+
import { promisify } from "util";
5+
6+
import * as portfinder from "portfinder";
7+
8+
import * as runtimes from "..";
9+
import * as backend from "../../backend";
10+
import * as discovery from "../discovery";
11+
import { logger } from "../../../../logger";
12+
import { runWithVirtualEnv } from "../../../../functions/python";
13+
import { FirebaseError } from "../../../../error";
14+
import { Build } from "../../build";
15+
16+
const LATEST_VERSION: runtimes.Runtime = "python310";
17+
18+
/**
19+
* Create a runtime delegate for the Python runtime, if applicable.
20+
*
21+
* @param context runtimes.DelegateContext
22+
* @return Delegate Python runtime delegate
23+
*/
24+
export async function tryCreateDelegate(
25+
context: runtimes.DelegateContext
26+
): Promise<Delegate | undefined> {
27+
const requirementsTextPath = path.join(context.sourceDir, "requirements.txt");
28+
29+
if (!(await promisify(fs.exists)(requirementsTextPath))) {
30+
logger.debug("Customer code is not Python code.");
31+
return;
32+
}
33+
const runtime = context.runtime ? context.runtime : LATEST_VERSION;
34+
if (!runtimes.isValidRuntime(runtime)) {
35+
throw new FirebaseError(`Runtime ${runtime} is not a valid Python runtime`);
36+
}
37+
return Promise.resolve(new Delegate(context.projectId, context.sourceDir, runtime));
38+
}
39+
40+
export class Delegate implements runtimes.RuntimeDelegate {
41+
public readonly name = "python";
42+
constructor(
43+
private readonly projectId: string,
44+
private readonly sourceDir: string,
45+
public readonly runtime: runtimes.Runtime
46+
) {}
47+
48+
private _bin = "";
49+
private _modulesDir = "";
50+
51+
get bin(): string {
52+
if (this._bin === "") {
53+
this._bin = this.getPythonBinary();
54+
}
55+
return this._bin;
56+
}
57+
58+
async modulesDir(): Promise<string> {
59+
if (!this._modulesDir) {
60+
const child = runWithVirtualEnv(
61+
[
62+
this.bin,
63+
"-c",
64+
'"import firebase_functions; import os; print(os.path.dirname(firebase_functions.__file__))"',
65+
],
66+
this.sourceDir,
67+
{}
68+
);
69+
let out = "";
70+
child.stdout?.on("data", (chunk: Buffer) => {
71+
const chunkString = chunk.toString();
72+
out = out + chunkString;
73+
logger.debug(`stdout: ${chunkString}`);
74+
});
75+
await new Promise((resolve, reject) => {
76+
child.on("exit", resolve);
77+
child.on("error", reject);
78+
});
79+
this._modulesDir = out.trim();
80+
}
81+
return this._modulesDir;
82+
}
83+
84+
getPythonBinary(): string {
85+
if (process.platform === "win32") {
86+
// There is no easy way to get specific version of python executable in Windows.
87+
return "python.exe";
88+
}
89+
if (this.runtime === "python310") {
90+
return "python3.10";
91+
} else if (this.runtime === "python311") {
92+
return "python3.11";
93+
}
94+
return "python";
95+
}
96+
97+
validate(): Promise<void> {
98+
// TODO: make sure firebase-functions is included as a dep
99+
return Promise.resolve();
100+
}
101+
102+
watch(): Promise<() => Promise<void>> {
103+
return Promise.resolve(() => Promise.resolve());
104+
}
105+
106+
async build(): Promise<void> {
107+
return Promise.resolve();
108+
}
109+
110+
async serveAdmin(port: number, envs: backend.EnvironmentVariables): Promise<() => Promise<void>> {
111+
const modulesDir = await this.modulesDir();
112+
const envWithAdminPort = {
113+
...envs,
114+
ADMIN_PORT: port.toString(),
115+
};
116+
const args = [this.bin, path.join(modulesDir, "private", "serving.py")];
117+
logger.debug(
118+
`Running admin server with args: ${JSON.stringify(args)} and env: ${JSON.stringify(
119+
envWithAdminPort
120+
)} in ${this.sourceDir}`
121+
);
122+
const childProcess = runWithVirtualEnv(args, this.sourceDir, envWithAdminPort);
123+
return Promise.resolve(async () => {
124+
await fetch(`http://127.0.0.1:${port}/__/quitquitquit`);
125+
const quitTimeout = setTimeout(() => {
126+
if (!childProcess.killed) {
127+
childProcess.kill("SIGKILL");
128+
}
129+
}, 10_000);
130+
clearTimeout(quitTimeout);
131+
});
132+
}
133+
134+
async discoverBuild(
135+
_configValues: backend.RuntimeConfigValues,
136+
envs: backend.EnvironmentVariables
137+
): Promise<Build> {
138+
let discovered = await discovery.detectFromYaml(this.sourceDir, this.projectId, this.runtime);
139+
if (!discovered) {
140+
const adminPort = await portfinder.getPortPromise({
141+
port: 8081,
142+
});
143+
const killProcess = await this.serveAdmin(adminPort, envs);
144+
try {
145+
discovered = await discovery.detectFromPort(adminPort, this.projectId, this.runtime);
146+
} finally {
147+
await killProcess();
148+
}
149+
}
150+
return discovered;
151+
}
152+
}

0 commit comments

Comments
 (0)