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

Supporting running FastAPI apps via Uvicorn in Python runtimes #2254

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
*.userosscache
*.sln.docstates

# .NET global.json file to set the SDK version per user
global.json

# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs

Expand Down
1 change: 1 addition & 0 deletions images/runtime/python/3.10/bullseye.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ LABEL io.buildpacks.stack.id="oryx.stacks.skeleton"
RUN ${IMAGES_DIR}/runtime/python/install-dependencies.sh
RUN pip install --upgrade pip \
&& pip install gunicorn \
&& pip install uvicorn \
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, I don't think we can add these for the existing images as it can have some implications in terms of size and side-effects for existing customers.
The easiest ways I would see is:

  • Waiting for a new Python version and only enable this here.
  • Create a new image just for this case, which is a bit annoying as well.
    We can sync offline if you want.

&& pip install debugpy \
&& pip install viztracer==0.15.6 \
&& pip install vizplugins==0.1.3 \
Expand Down
1 change: 1 addition & 0 deletions images/runtime/python/3.11/bullseye.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ LABEL io.buildpacks.stack.id="oryx.stacks.skeleton"
RUN ${IMAGES_DIR}/runtime/python/install-dependencies.sh
RUN pip install --upgrade pip \
&& pip install gunicorn \
&& pip install uvicorn \
&& pip install debugpy \
&& pip install viztracer==0.15.6 \
&& pip install vizplugins==0.1.3 \
Expand Down
1 change: 1 addition & 0 deletions images/runtime/python/3.7/bullseye.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ LABEL io.buildpacks.stack.id="oryx.stacks.skeleton"
RUN ${IMAGES_DIR}/runtime/python/install-dependencies.sh
RUN pip install --upgrade pip \
&& pip install gunicorn \
&& pip install uvicorn \
&& pip install debugpy \
&& pip install viztracer==0.15.6 \
&& pip install vizplugins==0.1.3 \
Expand Down
1 change: 1 addition & 0 deletions images/runtime/python/3.8/bullseye.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ LABEL io.buildpacks.stack.id="oryx.stacks.skeleton"
RUN ${IMAGES_DIR}/runtime/python/install-dependencies.sh
RUN pip install --upgrade pip \
&& pip install gunicorn \
&& pip install uvicorn \
&& pip install debugpy \
&& pip install viztracer==0.15.6 \
&& pip install vizplugins==0.1.3 \
Expand Down
1 change: 1 addition & 0 deletions images/runtime/python/template.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ LABEL io.buildpacks.stack.id="oryx.stacks.skeleton"
RUN ${IMAGES_DIR}/runtime/python/install-dependencies.sh
RUN pip install --upgrade pip \
&& pip install gunicorn \
&& pip install uvicorn \
&& pip install debugpy \
&& pip install viztracer==0.15.6 \
&& pip install vizplugins==0.1.3 \
Expand Down
113 changes: 100 additions & 13 deletions src/startupscriptgenerator/src/python/frameworks.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,46 +6,125 @@
package main

import (
"bufio"
"common"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"fmt"
"strings"
)

type PyAppFramework interface {
Name() string
GetGunicornModuleArg() string
GetDebuggableModule() string
detect() bool
Name() string
GetGunicornModuleArg() string
GetUvicornModuleArg() string
GetDebuggableModule() string
detect() bool
}

type djangoDetector struct {
appPath string
venvName string
wsgiModule string
appPath string
venvName string
wsgiModule string
}

type flaskDetector struct {
appPath string
mainFile string
appPath string
mainFile string
}

type fastApiDetector struct {
appPath string
mainFile string
launchPath string
}

func DetectFramework(appPath string, venvName string) PyAppFramework {
var detector PyAppFramework

detector = &djangoDetector{ appPath: appPath, venvName: venvName }
detector = &fastApiDetector{appPath: appPath}
if detector.detect() {
return detector
}

detector = &djangoDetector{appPath: appPath, venvName: venvName}
if detector.detect() {
return detector
}

detector = &flaskDetector{ appPath: appPath }
detector = &flaskDetector{appPath: appPath}
if detector.detect() {
return detector
}

return nil
}

func (detector *fastApiDetector) Name() string {
return "FastAPI"
}

// Checks if the app is based on FastAPI
func (detector *fastApiDetector) detect() bool {
logger := common.GetLogger("python.frameworks.fastApiDetector.detect")
defer logger.Shutdown()

filesToSearch := []string{"app.py", "main.py"}

for _, file := range filesToSearch {
fullPath := filepath.Join(detector.appPath, file)
if common.FileExists(fullPath) {
// Match on something that looks like the following in the main file:
// app = FastAPI()
// app = fastapi.FastAPI()
// app = FastAPI(
f, err := os.Open(fullPath)
if err != nil {
logger.LogWarning("Failed to open and read file %s for FastAPI detection: %s", fullPath, err.Error())
continue
}

defer f.Close()

scanner := bufio.NewScanner(f)
for scanner.Scan() {
if strings.Contains(scanner.Text(), "FastAPI(") {
decl := strings.Split(scanner.Text(), " ")
if len(decl) >= 3 && decl[1] == "=" {
detector.mainFile = fullPath
mainObjName := decl[0]
relativePath, err := filepath.Rel(detector.appPath, fullPath)
if err != nil {
continue
}

// dir1/dir2/main.py -> dir1.dir2.main
mainPath := strings.ReplaceAll(strings.TrimSuffix(relativePath, ".py"), "/", ".")

detector.launchPath = fmt.Sprintf("%s:%s", mainPath, mainObjName)
return true
}
}
}
}
}

return false
}

func (detector *fastApiDetector) GetGunicornModuleArg() string {
return ""
}

func (detector *fastApiDetector) GetUvicornModuleArg() string {
return detector.launchPath
}

func (detector *fastApiDetector) GetDebuggableModule() string {
return ""
}

func (detector *djangoDetector) Name() string {
return "Django"
}
Expand Down Expand Up @@ -81,6 +160,10 @@ func (detector *djangoDetector) GetGunicornModuleArg() string {
return detector.wsgiModule
}

func (detector *djangoDetector) GetUvicornModuleArg() string {
return ""
}

func (detector *djangoDetector) GetDebuggableModule() string {
if !common.FileExists(filepath.Join(detector.appPath, "manage.py")) {
logger := common.GetLogger("python.frameworks.djangoDetector.GetDebuggableModule")
Expand Down Expand Up @@ -122,10 +205,14 @@ func (detector *flaskDetector) detect() bool {

// TODO: detect correct variable name from a list of common names (app, application, etc.)
func (detector *flaskDetector) GetGunicornModuleArg() string {
module := detector.mainFile[0 : len(detector.mainFile) - 3] // Remove the '.py' from the end
module := detector.mainFile[0 : len(detector.mainFile)-3] // Remove the '.py' from the end
return module + ":app"
}

func (detector *flaskDetector) GetUvicornModuleArg() string {
return ""
}

func (detector *flaskDetector) GetDebuggableModule() string {
if !common.FileExists(filepath.Join(detector.appPath, "wsgi.py")) &&
!common.FileExists(filepath.Join(detector.appPath, "app.py")) {
Expand Down
38 changes: 25 additions & 13 deletions src/startupscriptgenerator/src/python/scriptgenerator.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ func (gen *PythonStartupScriptGenerator) GenerateEntrypointScript() string {
appType := "" // "Django", "Flask", etc.
appDebugAdapter := "" // Used debugger adapter
appDirectory := ""
appModule := "" // Suspected entry module in app
gunicornModule := "" // Gunicorn entry module in app
uvicornModule := "" // Uvicorn entry module in app
appDebugModule := "" // Command to run under a debugger in case debugging mode was requested

command := gen.UserStartupCommand // A custom command takes precedence over any framework defaults
Expand All @@ -102,18 +103,19 @@ func (gen *PythonStartupScriptGenerator) GenerateEntrypointScript() string {
println("Detected an app based on " + appFw.Name())
appType = appFw.Name()
appDirectory = gen.getAppPath()
appModule = appFw.GetGunicornModuleArg()
gunicornModule = appFw.GetGunicornModuleArg()
uvicornModule = appFw.GetUvicornModuleArg()
appDebugModule = appFw.GetDebuggableModule()
} else {
println("No framework detected; using default app from " + gen.DefaultAppPath)
logger.LogInformation("Using default app.")
appType = "Default"
appDirectory = gen.DefaultAppPath
appModule = gen.DefaultAppModule
gunicornModule = gen.DefaultAppModule
appDebugModule = gen.DefaultAppDebugModule
}

if appModule != "" {
if gunicornModule != "" || uvicornModule != "" {
// Patch all legacy ptvsd debug adaptor calls to debugpy
if gen.DebugAdapter == "ptvsd" {
gen.DebugAdapter = "debugpy"
Expand All @@ -128,10 +130,14 @@ func (gen *PythonStartupScriptGenerator) GenerateEntrypointScript() string {
}

appDebugAdapter = gen.DebugAdapter
} else if uvicornModule != "" {
logger.LogInformation(fmt.Sprintf("Generating Uvicorn startup command for module '%s'.", uvicornModule))
println(fmt.Sprintf(GeneratingCommandMessage, "uvicorn", uvicornModule))
command = gen.buildUvicornCommandForModule(uvicornModule, appDirectory)
} else {
logger.LogInformation("Generating command for appModule.")
println(fmt.Sprintf(GeneratingCommandMessage, "gunicorn", appModule))
command = gen.buildGunicornCommandForModule(appModule, appDirectory)
logger.LogInformation(fmt.Sprintf("Generating Gunicorn startup command for module '%s'.", gunicornModule))
println(fmt.Sprintf(GeneratingCommandMessage, "gunicorn", gunicornModule))
command = gen.buildGunicornCommandForModule(gunicornModule, appDirectory)
}
}
}
Expand All @@ -141,7 +147,7 @@ func (gen *PythonStartupScriptGenerator) GenerateEntrypointScript() string {
logger.LogProperties(
"Finalizing script",
map[string]string{"appType": appType, "appDebugAdapter": appDebugAdapter,
"appModule": appModule, "venv": gen.Manifest.VirtualEnvName})
"gunicornModule": gunicornModule, "uvicornModule": uvicornModule, "venv": gen.Manifest.VirtualEnvName})

var runScript = scriptBuilder.String()
return runScript
Expand Down Expand Up @@ -277,10 +283,10 @@ func (gen *PythonStartupScriptGenerator) buildGunicornCommandForModule(module st
args := "--timeout 600 --access-logfile '-' --error-logfile '-'"

pythonUseGunicornConfigFromPath := os.Getenv(consts.PythonGunicornConfigPathEnvVarName)
if pythonUseGunicornConfigFromPath != "" {
args = appendArgs(args, "-c "+pythonUseGunicornConfigFromPath)
if pythonUseGunicornConfigFromPath != "" {
args = appendArgs(args, "-c "+pythonUseGunicornConfigFromPath)
}

pythonEnableGunicornMultiWorkers := common.GetBooleanEnvironmentVariable(consts.PythonEnableGunicornMultiWorkersEnvVarName)

if pythonEnableGunicornMultiWorkers {
Expand All @@ -290,15 +296,15 @@ func (gen *PythonStartupScriptGenerator) buildGunicornCommandForModule(module st
pythonCustomWorkerNum := os.Getenv(consts.PythonGunicornCustomWorkerNum)
pythonCustomThreadNum := os.Getenv(consts.PythonGunicornCustomThreadNum)
workers := ""
if (pythonCustomWorkerNum != "") {
if pythonCustomWorkerNum != "" {
workers = pythonCustomWorkerNum
} else {
workers = strconv.Itoa((2 * runtime.NumCPU()) + 1)
// 2N+1 number of workers is recommended by Gunicorn docs.
// Where N is the number of CPU threads.
}
args = appendArgs(args, "--workers="+workers)
if (pythonCustomThreadNum != "") {
if pythonCustomThreadNum != "" {
args = appendArgs(args, "--threads="+pythonCustomThreadNum)
}
}
Expand All @@ -318,6 +324,12 @@ func (gen *PythonStartupScriptGenerator) buildGunicornCommandForModule(module st
return "gunicorn " + module
}

// Produces the uvicorn command to run the app.
// `module` is of the pattern "<dotted module path>:<variable name>".
func (gen *PythonStartupScriptGenerator) buildUvicornCommandForModule(module string, appDir string) string {
return fmt.Sprintf("uvicorn %s --port $PORT --host $HOST", module)
}

func (gen *PythonStartupScriptGenerator) shouldStartAppInDebugMode() bool {
logger := common.GetLogger("python.scriptgenerator.shouldStartAppInDebugMode")
defer logger.Shutdown()
Expand Down
Loading