Skip to content

Commit 8454061

Browse files
Add FastAPI framework support to Oryx (#2904)
* Onboard fastapi detection * Update fastapi test * Update test * Add startupscript generator tests to unit-tests flow * Add startupscript generator tests to unit-tests flow * minor fixto tests workflow * minor fix to tests workflow * minor fix to tests * Pin uvicorn version * Use uvicorn worker * Pin uvicorn worker version * Include other files for platform detection * Update detection logic * Update detection logic * Enable flask detection only for python 3.14+ * Revert template.dockerfile changes * Revert "Revert template.dockerfile changes" This reverts commit ac8485f. * Remove uvicorn from debian based images * Update test app * Improve detection logic * Update fastapitests to use python 3.14 --------- Co-authored-by: Priya Chintalapati <vchintalapat@microsoft.com>
1 parent 0e4f93c commit 8454061

11 files changed

Lines changed: 398 additions & 10 deletions

File tree

.github/workflows/unit-tests.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,27 @@ jobs:
4545

4646
- name: Run BuildServer.Tests
4747
run: dotnet test tests/BuildServer.Tests/BuildServer.Tests.csproj --no-build --configuration Release --verbosity normal
48+
49+
- name: Setup Go
50+
uses: actions/setup-go@v5
51+
with:
52+
go-version: '1.26'
53+
54+
- name: Run Startup Script Generator Tests
55+
env:
56+
GOTOOLCHAIN: local
57+
run: |
58+
cd src/startupscriptgenerator/src
59+
GOROOT_DIR=$(go env GOROOT)
60+
cp -r "$(pwd)/common" "$GOROOT_DIR/src/common"
61+
for dir in */; do
62+
if [ "$dir" = "common/" ]; then
63+
continue
64+
fi
65+
if [ -f "$dir/go.mod" ]; then
66+
echo "Testing $dir..."
67+
cd "$dir"
68+
go test -v ./...
69+
cd ..
70+
fi
71+
done

build/constants.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
python-gunicorn-config-path-env-var-name: PYTHON_USE_GUNICORN_CONFIG_FROM_PATH
2626
python-gunicorn-custom-worker-num: PYTHON_GUNICORN_CUSTOM_WORKER_NUM
2727
python-gunicorn-custom-thread-num: PYTHON_GUNICORN_CUSTOM_THREAD_NUM
28+
python-disable-fastapi-detection-env-var-name: DISABLE_FASTAPI_DETECTION
2829
nginx-conf-file: NGINX_CONF_FILE
2930
outputs:
3031
- type: csharp

images/runtime/python/noble.Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ LABEL io.buildpacks.stack.id="oryx.stacks.skeleton"
8888
RUN ${IMAGES_DIR}/runtime/python/install-dependencies.sh
8989
RUN --mount=type=secret,id=pip_index_url,target=/run/secrets/pip_index_url \
9090
pip install --index-url $(cat /run/secrets/pip_index_url) --upgrade pip && \
91-
pip install --index-url $(cat /run/secrets/pip_index_url) gunicorn debugpy viztracer==0.15.6 vizplugins==0.1.3 && \
91+
pip install --index-url $(cat /run/secrets/pip_index_url) gunicorn uvicorn==0.46.0 uvicorn-worker==0.4.0 debugpy viztracer==0.15.6 vizplugins==0.1.3 && \
9292
ln -s /opt/startupcmdgen/startupcmdgen /usr/local/bin/oryx && \
9393
rm -rf /var/lib/apt/lists/* && \
9494
rm -rf /tmp/oryx

src/startupscriptgenerator/src/common/consts/ext_var_names.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,5 @@ const PythonEnableGunicornMultiWorkersEnvVarName string = "PYTHON_ENABLE_GUNICOR
2727
const PythonGunicornConfigPathEnvVarName string = "PYTHON_USE_GUNICORN_CONFIG_FROM_PATH"
2828
const PythonGunicornCustomWorkerNum string = "PYTHON_GUNICORN_CUSTOM_WORKER_NUM"
2929
const PythonGunicornCustomThreadNum string = "PYTHON_GUNICORN_CUSTOM_THREAD_NUM"
30+
const PythonDisableFastAPIDetectionEnvVarName string = "DISABLE_FASTAPI_DETECTION"
3031
const NginxConfFile string = "NGINX_CONF_FILE"

src/startupscriptgenerator/src/node/scriptgenerator_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,15 +132,15 @@ func ExampleNodeStartupScriptGenerator_getStartupCommandFromJsFile_simpleNodeCom
132132
// node a/b/c.js
133133
}
134134

135-
func ExampleNodeStartupScriptGenerator_getConfigJsCommand_returnsEmptyString_WhenUsePm2IsFalse(t *testing.T) {
135+
func TestNodeStartupScriptGenerator_getConfigJsCommand_returnsEmptyString_WhenUsePm2IsFalse(t *testing.T) {
136136
gen := &NodeStartupScriptGenerator{
137137
UsePm2: false,
138138
}
139139
command := gen.getConfigJsCommand("ecosystem.config.js")
140140
assert.Empty(t, command)
141141
}
142142

143-
func ExampleNodeStartupScriptGenerator_getConfigYamlCommand_returnsEmptyString_WhenUsePm2IsFalse(t *testing.T) {
143+
func TestNodeStartupScriptGenerator_getConfigYamlCommand_returnsEmptyString_WhenUsePm2IsFalse(t *testing.T) {
144144
gen := &NodeStartupScriptGenerator{
145145
UsePm2: false,
146146
}

src/startupscriptgenerator/src/python/frameworks.go

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ package main
77

88
import (
99
"common"
10+
"common/consts"
11+
"fmt"
1012
"io/ioutil"
1113
"path/filepath"
12-
"fmt"
14+
"strings"
1315
)
1416

1517
type PyAppFramework interface {
@@ -30,14 +32,27 @@ type flaskDetector struct {
3032
mainFile string
3133
}
3234

33-
func DetectFramework(appPath string, venvName string) PyAppFramework {
35+
type fastAPIDetector struct {
36+
appPath string
37+
mainFile string
38+
}
39+
40+
func DetectFramework(appPath string, venvName string, pythonVersion string) PyAppFramework {
3441
var detector PyAppFramework
3542

3643
detector = &djangoDetector{ appPath: appPath, venvName: venvName }
3744
if detector.detect() {
3845
return detector
3946
}
4047

48+
if isPythonVersionAtLeast(pythonVersion, 3, 14) &&
49+
!common.GetBooleanEnvironmentVariable(consts.PythonDisableFastAPIDetectionEnvVarName) {
50+
detector = &fastAPIDetector{ appPath: appPath }
51+
if detector.detect() {
52+
return detector
53+
}
54+
}
55+
4156
detector = &flaskDetector{ appPath: appPath }
4257
if detector.detect() {
4358
return detector
@@ -46,6 +61,23 @@ func DetectFramework(appPath string, venvName string) PyAppFramework {
4661
return nil
4762
}
4863

64+
// isPythonVersionAtLeast checks whether the given version string (e.g. "3.14.1")
65+
// is at least major.minor.
66+
func isPythonVersionAtLeast(version string, major int, minor int) bool {
67+
parts := strings.SplitN(version, ".", 3)
68+
if len(parts) < 2 {
69+
return false
70+
}
71+
var maj, min int
72+
if _, err := fmt.Sscan(parts[0], &maj); err != nil {
73+
return false
74+
}
75+
if _, err := fmt.Sscan(parts[1], &min); err != nil {
76+
return false
77+
}
78+
return maj > major || (maj == major && min >= minor)
79+
}
80+
4981
func (detector *djangoDetector) Name() string {
5082
return "Django"
5183
}
@@ -137,3 +169,56 @@ func (detector *flaskDetector) GetDebuggableModule() string {
137169
// Default is 127.0.0.1:5000 (https://flask.palletsprojects.com/en/1.1.x/api/#flask.Flask.run)
138170
return fmt.Sprintf("flask run --host $HOST --port $PORT")
139171
}
172+
173+
func (detector *fastAPIDetector) Name() string {
174+
return "FastAPI"
175+
}
176+
177+
// Checks if the app is based on FastAPI:
178+
// Returns true if a common entrypoint file both imports the fastapi package
179+
// AND creates a FastAPI application instance.
180+
func (detector *fastAPIDetector) detect() bool {
181+
logger := common.GetLogger("python.frameworks.fastAPIDetector.detect")
182+
defer logger.Shutdown()
183+
184+
filesToSearch := []string{"main.py", "app.py", "application.py", "server.py", "asgi.py", "api.py", "index.py", "run.py"}
185+
186+
for _, file := range filesToSearch {
187+
fullPath := filepath.Join(detector.appPath, file)
188+
if !common.FileExists(fullPath) {
189+
continue
190+
}
191+
192+
content, err := ioutil.ReadFile(fullPath)
193+
if err != nil {
194+
logger.LogError("Failed to read file '%s': %s", fullPath, err.Error())
195+
continue
196+
}
197+
198+
contentStr := string(content)
199+
200+
hasImport := strings.Contains(contentStr, "from fastapi") ||
201+
strings.Contains(contentStr, "import fastapi")
202+
hasFlask := strings.Contains(contentStr, "from flask")
203+
204+
if hasImport && !hasFlask {
205+
logger.LogInformation("Detected FastAPI app in '%s'.", file)
206+
detector.mainFile = file
207+
return true
208+
}
209+
}
210+
211+
return false
212+
}
213+
214+
// Returns the gunicorn module argument with uvicorn worker class for ASGI support.
215+
func (detector *fastAPIDetector) GetGunicornModuleArg() string {
216+
module := detector.mainFile[0 : len(detector.mainFile) - 3] // Remove the '.py' from the end
217+
return "-k uvicorn_worker.UvicornWorker " + module + ":app"
218+
}
219+
220+
func (detector *fastAPIDetector) GetDebuggableModule() string {
221+
module := detector.mainFile[0 : len(detector.mainFile) - 3] // Remove the '.py' from the end
222+
// Use uvicorn directly for debugging since it supports --reload
223+
return fmt.Sprintf("uvicorn %s:app --host $HOST --port $PORT", module)
224+
}

src/startupscriptgenerator/src/python/scriptgenerator.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ func (gen *PythonStartupScriptGenerator) GenerateEntrypointScript() string {
117117
logger.LogInformation("Permission added: %t", isPermissionAdded)
118118
command = common.ExtendPathForCommand(command, gen.getAppPath())
119119
} else {
120-
var appFw PyAppFramework = DetectFramework(gen.getAppPath(), gen.VirtualEnvName)
120+
var appFw PyAppFramework = DetectFramework(gen.getAppPath(), gen.VirtualEnvName, pythonVersion)
121121

122122
if appFw != nil {
123123
println("Detected an app based on " + appFw.Name())
@@ -379,8 +379,8 @@ func (gen *PythonStartupScriptGenerator) buildDebugPyCommandForModule(moduleAndA
379379
cdcmd = fmt.Sprintf("cd %s && ", appDir)
380380
}
381381

382-
pycmd := fmt.Sprintf("%spython -m debugpy --listen %s:%s %s -m %s",
383-
cdcmd, DefaultHost, gen.DebugPort, waitarg, moduleAndArgs)
382+
pycmd := fmt.Sprintf("python -m debugpy --listen %s:%s %s -m %s",
383+
DefaultHost, gen.DebugPort, waitarg, moduleAndArgs)
384384

385385
return cdcmd + pycmd
386386
}

src/startupscriptgenerator/src/python/scriptgenerator_test.go

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
package main
77

88
import (
9+
"os"
10+
"path/filepath"
911
"testing"
1012
"github.com/stretchr/testify/assert"
1113
)
@@ -25,7 +27,7 @@ func Test_ExamplePythonStartupScriptGenerator_buildGunicornCommandForModule_only
2527
assert.Equal(t, expected, actual)
2628
}
2729

28-
func ExamplePythonStartupScriptGenerator_buildGunicornCommandForModule_moduleAndPath(t *testing.T) {
30+
func Test_buildGunicornCommandForModule_moduleAndPath(t *testing.T) {
2931
// Arrange
3032
expected := "GUNICORN_CMD_ARGS=\"--timeout 600 --access-logfile '-' --error-logfile '-'" +
3133
" --chdir=/a/b/c\" gunicorn module.py"
@@ -40,7 +42,104 @@ func ExamplePythonStartupScriptGenerator_buildGunicornCommandForModule_moduleAnd
4042
assert.Equal(t, expected, actual)
4143
}
4244

43-
func ExamplePythonStartupScriptGenerator_buildGunicornCommandForModule_moduleAndPathAndHost(t *testing.T) {
45+
// FastAPI: gunicorn command with uvicorn worker class
46+
func Test_buildGunicornCommandForModule_FastAPI_withUvicornWorker(t *testing.T) {
47+
// Arrange
48+
expected := "GUNICORN_CMD_ARGS=\"--timeout 600 --access-logfile '-' --error-logfile '-'" +
49+
" --bind=0.0.0.0:80 --chdir=/home/site/wwwroot\" gunicorn " +
50+
"-k uvicorn_worker.UvicornWorker main:app"
51+
gen := PythonStartupScriptGenerator{
52+
BindPort: "80",
53+
}
54+
55+
// Act
56+
actual := gen.buildGunicornCommandForModule(
57+
"-k uvicorn_worker.UvicornWorker main:app", "/home/site/wwwroot")
58+
59+
// Assert
60+
assert.Equal(t, expected, actual)
61+
}
62+
63+
// FastAPI: debug command with uvicorn
64+
func Test_buildDebugPyCommandForModule_FastAPI(t *testing.T) {
65+
// Arrange
66+
expected := "cd /app && python -m debugpy --listen 0.0.0.0:5678 -m " +
67+
"uvicorn main:app --host $HOST --port $PORT"
68+
gen := PythonStartupScriptGenerator{
69+
DebugPort: "5678",
70+
}
71+
72+
// Act
73+
actual := gen.buildDebugPyCommandForModule(
74+
"uvicorn main:app --host $HOST --port $PORT", "/app")
75+
76+
// Assert
77+
assert.Equal(t, expected, actual)
78+
}
79+
80+
// FastAPI detection: main.py with FastAPI import, validates detection + all output methods
81+
func Test_fastAPIDetector_detect_mainPy(t *testing.T) {
82+
// Arrange
83+
dir := t.TempDir()
84+
os.WriteFile(filepath.Join(dir, "main.py"),
85+
[]byte("from fastapi import FastAPI\napp = FastAPI()\n"), 0644)
86+
87+
detector := &fastAPIDetector{appPath: dir}
88+
89+
// Act & Assert
90+
assert.True(t, detector.detect())
91+
assert.Equal(t, "main.py", detector.mainFile)
92+
assert.Equal(t, "-k uvicorn_worker.UvicornWorker main:app", detector.GetGunicornModuleArg())
93+
assert.Equal(t, "uvicorn main:app --host $HOST --port $PORT", detector.GetDebuggableModule())
94+
}
95+
96+
// FastAPI detection: no FastAPI import should return false
97+
func Test_fastAPIDetector_detect_notFastAPI(t *testing.T) {
98+
// Arrange
99+
dir := t.TempDir()
100+
os.WriteFile(filepath.Join(dir, "main.py"),
101+
[]byte("from flask import Flask\napp = Flask(__name__)\n"), 0644)
102+
103+
detector := &fastAPIDetector{appPath: dir}
104+
105+
// Act & Assert
106+
assert.False(t, detector.detect())
107+
}
108+
109+
// DetectFramework: FastAPI detected before Flask when app.py has FastAPI
110+
func Test_DetectFramework_FastAPI_beforeFlask(t *testing.T) {
111+
// Arrange
112+
dir := t.TempDir()
113+
os.WriteFile(filepath.Join(dir, "app.py"),
114+
[]byte("from fastapi import FastAPI\napp = FastAPI()\n"), 0644)
115+
116+
// Act
117+
fw := DetectFramework(dir, "", "3.14.0")
118+
119+
// Assert
120+
assert.NotNil(t, fw)
121+
assert.Equal(t, "FastAPI", fw.Name())
122+
}
123+
124+
// DetectFramework: Django wins over FastAPI when wsgi.py exists in subdirectory
125+
func Test_DetectFramework_Django_overFastAPI(t *testing.T) {
126+
// Arrange
127+
dir := t.TempDir()
128+
subDir := filepath.Join(dir, "myproject")
129+
os.MkdirAll(subDir, 0755)
130+
os.WriteFile(filepath.Join(subDir, "wsgi.py"), []byte(""), 0644)
131+
os.WriteFile(filepath.Join(dir, "main.py"),
132+
[]byte("from fastapi import FastAPI\napp = FastAPI()\n"), 0644)
133+
134+
// Act
135+
fw := DetectFramework(dir, "", "3.14.0")
136+
137+
// Assert
138+
assert.NotNil(t, fw)
139+
assert.Equal(t, "Django", fw.Name())
140+
}
141+
142+
func Test_buildGunicornCommandForModule_moduleAndPathAndHost(t *testing.T) {
44143
// Arrange
45144
expected := "GUNICORN_CMD_ARGS=\"--timeout 600 --access-logfile '-' --error-logfile '-'" +
46145
" --bind=0.0.0.0:12345 --chdir=/a/b/c\" gunicorn module.py"

0 commit comments

Comments
 (0)