package python import ( "fmt" "maps" "regexp" "strings" "github.com/charmbracelet/log" "github.com/railwayapp/railpack/core/generate" "github.com/railwayapp/railpack/core/plan" "github.com/railwayapp/railpack/internal/utils" ) const ( DEFAULT_PYTHON_VERSION = "3.13" UV_CACHE_DIR = "/opt/uv-cache" PIP_CACHE_DIR = "/opt/pip-cache" VENV_PATH = "/app/.venv" LOCAL_BIN_PATH = "/root/.local/bin" PLAYWRIGHT_CACHE_DIR = "/root/.cache/ms-playwright" ) type PythonProvider struct{} func (p *PythonProvider) Name() string { return "python" } func (p *PythonProvider) Initialize(ctx *generate.GenerateContext) error { return nil } func (p *PythonProvider) Detect(ctx *generate.GenerateContext) (bool, error) { hasPython := p.getMainPythonFile(ctx) != "" || p.hasRequirements(ctx) || p.hasPyproject(ctx) || p.hasPipfile(ctx) return hasPython, nil } func (p *PythonProvider) Plan(ctx *generate.GenerateContext) error { p.InstallMisePackages(ctx, ctx.GetMiseStepBuilder()) install := ctx.NewCommandStep("install") install.AddInput(plan.NewStepLayer(p.GetBuilderDeps(ctx).Name())) install.Secrets = []string{} install.UseSecretsWithPrefixes([]string{"PYTHON", "PIP", "PIPX", "UV", "PDM", "POETRY"}) build := ctx.NewCommandStep("build") installOutputs := []string{} if p.hasRequirements(ctx) { installOutputs = p.InstallPip(ctx, install) } else if p.hasPyproject(ctx) && p.hasUv(ctx) { installOutputs = p.InstallUv(ctx, install) build.AddCommands([]plan.Command{ // the project is not installed during the install phase, because it requires the project source plan.NewExecCommand("uv sync --locked --no-dev --no-editable"), }) } else if p.hasPyproject(ctx) && p.hasPoetry(ctx) { installOutputs = p.InstallPoetry(ctx, install) } else if p.hasPyproject(ctx) && p.hasPdm(ctx) { installOutputs = p.InstallPDM(ctx, install) } else if p.hasPipfile(ctx) { installOutputs = p.InstallPipenv(ctx, install) } if p.usesDep(ctx, "playwright") { ctx.Logger.LogInfo("Installing Playwright chromium browser") // --only-shell installs only the headless shell version (chromium_headless_shell) // This is smaller and more appropriate for server environments than full chromium install.AddCommand(plan.NewExecCommand("playwright install --only-shell")) // Include Playwright browser cache so browsers are available in deploy stage installOutputs = append(installOutputs, PLAYWRIGHT_CACHE_DIR) } p.addMetadata(ctx) build.AddInput(plan.NewStepLayer(install.Name())) build.AddInput(ctx.NewLocalLayer()) ctx.Deploy.StartCmd = p.GetStartCommand(ctx) maps.Copy(ctx.Deploy.Variables, p.GetPythonEnvVars(ctx)) installArtifacts := plan.NewStepLayer(build.Name(), plan.Filter{ Include: installOutputs, }) p.AddRuntimeDeps(ctx) ctx.Deploy.AddInputs([]plan.Layer{ ctx.GetMiseStepBuilder().GetLayer(), installArtifacts, plan.NewStepLayer(build.Name(), plan.Filter{ Include: []string{"."}, Exclude: []string{strings.TrimPrefix(VENV_PATH, "/app/")}, }), }) return nil } func (p *PythonProvider) GetStartCommand(ctx *generate.GenerateContext) string { startCommand := "" if p.isDjango(ctx) { startCommand = p.getDjangoStartCommand(ctx) } mainPythonFile := p.getMainPythonFile(ctx) hasMainPythonFile := mainPythonFile != "" if p.isFasthtml(ctx) && hasMainPythonFile && p.usesDep(ctx, "uvicorn") { startCommand = "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000}" } if p.isFastapi(ctx) && hasMainPythonFile && p.usesDep(ctx, "uvicorn") { startCommand = "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000}" } if p.isFlask(ctx) && hasMainPythonFile && p.usesDep(ctx, "gunicorn") { startCommand = "gunicorn --bind 0.0.0.0:${PORT:-8000} main:app" } if startCommand == "" && hasMainPythonFile { startCommand = fmt.Sprintf("python %s", mainPythonFile) } return startCommand } func (p *PythonProvider) getMainPythonFile(ctx *generate.GenerateContext) string { for _, file := range []string{"main.py", "app.py", "start.py", "bot.py", "hello.py", "server.py"} { if ctx.App.HasFile(file) { return file } } return "" } func (p *PythonProvider) CleansePlan(buildPlan *plan.BuildPlan) {} func (p *PythonProvider) StartCommandHelp() string { return "To start your Python application, Railpack will automatically:\n\n" + "1. Start FastHTML projects with uvicorn\n" + "2. Start FastAPI projects with uvicorn\n" + "3. Start Flask projects with gunicorn\n" + "4. Start Django projects with the gunicorn production server\n\n" + "Otherwise, it will run the main.py or app.py file in your project root" } func (p *PythonProvider) InstallUv(ctx *generate.GenerateContext, install *generate.CommandStepBuilder) []string { ctx.Logger.LogInfo("Using uv") install.AddCache(ctx.Caches.AddCache("uv", UV_CACHE_DIR)) install.AddEnvVars(map[string]string{ "UV_COMPILE_BYTECODE": "1", "UV_LINK_MODE": "copy", "UV_CACHE_DIR": UV_CACHE_DIR, "UV_PYTHON_DOWNLOADS": "never", "VIRTUAL_ENV": VENV_PATH, }) install.AddEnvVars(p.GetPythonEnvVars(ctx)) p.copyInstallFiles(ctx, install) installCommands := []plan.Command{ plan.NewPathCommand(LOCAL_BIN_PATH), plan.NewPathCommand(VENV_PATH + "/bin"), // if we exclude workspace packages, uv.lock will fail the frozen test and the user will get an error // to avoid this, we (a) detect if workspace packages are required (b) if they aren't, we don't include project // source in order to optimize layer caching (c) install project in the build phase. plan.NewExecCommand("uv sync --locked --no-dev --no-install-project"), } install.AddCommands(installCommands) return []string{VENV_PATH} } func (p *PythonProvider) InstallPipenv(ctx *generate.GenerateContext, install *generate.CommandStepBuilder) []string { ctx.Logger.LogInfo("Using pipenv") install.AddEnvVars(p.GetPythonEnvVars(ctx)) install.AddEnvVars(map[string]string{ "PIPENV_CHECK_UPDATE": "false", "PIPENV_VENV_IN_PROJECT": "1", "PIPENV_IGNORE_VIRTUALENVS": "1", }) install.AddCommands([]plan.Command{ plan.NewPathCommand(LOCAL_BIN_PATH), plan.NewPathCommand(VENV_PATH + "/bin"), }) if ctx.App.HasFile("Pipfile.lock") { install.AddCommands([]plan.Command{ plan.NewCopyCommand("Pipfile"), plan.NewCopyCommand("Pipfile.lock"), plan.NewExecCommand("pipenv install --deploy --ignore-pipfile"), }) } else { install.AddCommands([]plan.Command{ plan.NewCopyCommand("Pipfile"), plan.NewExecCommand("pipenv install --skip-lock"), }) } return []string{VENV_PATH} } func (p *PythonProvider) InstallPDM(ctx *generate.GenerateContext, install *generate.CommandStepBuilder) []string { ctx.Logger.LogInfo("Using pdm") install.AddEnvVars(p.GetPythonEnvVars(ctx)) install.AddEnvVars(map[string]string{ "PDM_CHECK_UPDATE": "false", }) p.copyInstallFiles(ctx, install) installCommands := []plan.Command{ plan.NewPathCommand(LOCAL_BIN_PATH), plan.NewPathCommand(VENV_PATH + "/bin"), plan.NewExecCommand("pdm install --check --prod --no-editable"), } install.AddCommands(installCommands) return []string{VENV_PATH} } func (p *PythonProvider) InstallPoetry(ctx *generate.GenerateContext, install *generate.CommandStepBuilder) []string { ctx.Logger.LogInfo("Using poetry") install.AddEnvVars(p.GetPythonEnvVars(ctx)) install.AddEnvVars(map[string]string{ "VIRTUAL_ENV": VENV_PATH, "POETRY_VIRTUALENVS_PATH": VENV_PATH, "POETRY_VIRTUALENVS_IN_PROJECT": "true", }) p.copyInstallFiles(ctx, install) installCommands := []plan.Command{ plan.NewPathCommand(LOCAL_BIN_PATH), plan.NewPathCommand(VENV_PATH + "/bin"), plan.NewExecCommand("poetry install --no-interaction --no-ansi --only main --no-root"), } install.AddCommands(installCommands) return []string{VENV_PATH} } func (p *PythonProvider) InstallPip(ctx *generate.GenerateContext, install *generate.CommandStepBuilder) []string { ctx.Logger.LogInfo("Using pip") install.AddCache(ctx.Caches.AddCache("pip", PIP_CACHE_DIR)) install.AddEnvVars(p.GetPythonEnvVars(ctx)) install.AddEnvVars(map[string]string{ "PIP_CACHE_DIR": PIP_CACHE_DIR, "VIRTUAL_ENV": VENV_PATH, }) install.AddCommands([]plan.Command{ plan.NewExecCommand(fmt.Sprintf("python -m venv %s", VENV_PATH)), plan.NewPathCommand(VENV_PATH + "/bin"), }) p.copyInstallFiles(ctx, install) install.AddCommands([]plan.Command{ plan.NewExecCommand("pip install -r requirements.txt"), }) return []string{VENV_PATH} } func (p *PythonProvider) AddRuntimeDeps(ctx *generate.GenerateContext) { for dep, requiredPkgs := range pythonRuntimeDepRequirements { if p.usesDep(ctx, dep) { ctx.Logger.LogInfo("Installing runtime apt packages for %s: %v", dep, requiredPkgs) ctx.Deploy.AddAptPackages(requiredPkgs) } } if p.usesPostgres(ctx) { ctx.Deploy.AddAptPackages([]string{"libpq5"}) } if p.usesMysql(ctx) { ctx.Deploy.AddAptPackages([]string{"default-mysql-client"}) } } func (p *PythonProvider) GetBuilderDeps(ctx *generate.GenerateContext) *generate.MiseStepBuilder { miseStep := ctx.GetMiseStepBuilder() // certain packages require apt libraries in order to properly build. We shouldn't handle all cases, but we attempt // to cover as many popular packages as possible. for dep, requiredPkgs := range pythonBuildDepRequirements { if p.usesDep(ctx, dep) { ctx.Logger.LogInfo("Installing build apt packages for %s: %v", dep, requiredPkgs) miseStep.SupportingAptPackages = append(miseStep.SupportingAptPackages, requiredPkgs...) } } // detecting database support is multi-faceted, so we special case them // note that these packages do *not* persist past the build phase and must be re-installed in the runtime if needed if p.usesPostgres(ctx) { miseStep.SupportingAptPackages = append(miseStep.SupportingAptPackages, "libpq-dev") } if p.usesMysql(ctx) { miseStep.SupportingAptPackages = append(miseStep.SupportingAptPackages, "default-libmysqlclient-dev") } return miseStep } func (p *PythonProvider) InstallMisePackages(ctx *generate.GenerateContext, miseStep *generate.MiseStepBuilder) { python := miseStep.Default("python", DEFAULT_PYTHON_VERSION) if envVersion, varName := ctx.Env.GetConfigVariable("PYTHON_VERSION"); envVersion != "" { miseStep.Version(python, envVersion, varName) } if runtimeFile, err := ctx.App.ReadFile("runtime.txt"); err == nil { miseStep.Version(python, utils.ExtractSemverVersion(string(runtimeFile)), "runtime.txt") } if pipfileVersion, pipfileVarName := parseVersionFromPipfile(ctx); pipfileVersion != "" { miseStep.Version(python, pipfileVersion, fmt.Sprintf("Pipfile > %s", pipfileVarName)) } // Collect all packages that will be used by the provider packages := []string{"python"} // Install package managers if p.hasPoetry(ctx) || p.hasPdm(ctx) || p.hasPipfile(ctx) { miseStep.Default("pipx", "latest") packages = append(packages, "pipx") // prefer to use uv tooling as much as we can miseStep.Variables["MISE_PIPX_UVX"] = "true" } if p.hasPoetry(ctx) { // as of 2025-10-18 the default mise poetry backend is asdf, which is very poorly maintained and has caused build // issues for users, which is why we install poetry via pipx here. miseStep.Default("pipx:poetry", "latest") packages = append(packages, "pipx:poetry") } if p.hasPdm(ctx) { miseStep.Default("pipx:pdm", "latest") packages = append(packages, "pipx:pdm") } if p.hasUv(ctx) { miseStep.Default("uv", "latest") packages = append(packages, "uv") } if p.hasPipfile(ctx) { miseStep.Default("pipx:pipenv", "latest") packages = append(packages, "pipx:pipenv") } miseStep.UseMiseVersions(ctx, packages) // Disable Python compilation to avoid incompatibility issues with some packages // https://mise.jdx.dev/lang/python.html#python.compile miseStep.Variables["MISE_PYTHON_COMPILE"] = "false" } func (p *PythonProvider) GetPythonEnvVars(ctx *generate.GenerateContext) map[string]string { return map[string]string{ "PYTHONFAULTHANDLER": "1", "PYTHONUNBUFFERED": "1", "PYTHONHASHSEED": "random", "PYTHONDONTWRITEBYTECODE": "1", "PIP_DISABLE_PIP_VERSION_CHECK": "1", "PIP_DEFAULT_TIMEOUT": "100", } } func (p *PythonProvider) copyInstallFiles(ctx *generate.GenerateContext, install *generate.CommandStepBuilder) { if p.installNeedsAllFiles(ctx) { install.AddInput(ctx.NewLocalLayer()) return } patterns := []string{ "requirements.txt", "pyproject.toml", "Pipfile", "poetry.lock", "uv.lock", "pdm.lock", } for _, pattern := range patterns { if files, err := ctx.App.FindFiles(pattern); err == nil { for _, file := range files { install.AddCommand(plan.NewCopyCommand(file)) } } } } // inspect python dependency files and determine if local packages are referenced, and therefore all files are required // for installation. func (p *PythonProvider) installNeedsAllFiles(ctx *generate.GenerateContext) bool { if requirementsContent, err := ctx.App.ReadFile("requirements.txt"); err == nil { return strings.Contains(requirementsContent, "file://") } // inspect pyproject.toml for local path references or uv workspace usage if pyprojectContent, err := ctx.App.ReadFile("pyproject.toml"); err == nil { if strings.Contains(pyprojectContent, "file://") || strings.Contains(pyprojectContent, "path = ") { return true } } // TODO just having a `uv.tool.workspace` key doesn't necessarily mean you are listing a workspace item as a dependency // parse TOML using existing helper to check for tool.uv.workspace key var pyproject map[string]any if err := ctx.App.ReadTOML("pyproject.toml", &pyproject); err == nil { if tool, ok := pyproject["tool"].(map[string]any); ok { if uv, ok := tool["uv"].(map[string]any); ok { if _, exists := uv["workspace"]; exists { return true } } } } else { log.Infof("Failed to read pyproject.toml: %v", err) } return false } func (p *PythonProvider) usesBinaryPsycopg(ctx *generate.GenerateContext) bool { return p.usesDep(ctx, "psycopg2-binary") || p.usesDep(ctx, "psycopg[binary]") } func (p *PythonProvider) usesPostgres(ctx *generate.GenerateContext) bool { if p.usesBinaryPsycopg(ctx) { return false } djangoPythonRe := regexp.MustCompile(`django.db.backends.postgresql`) containsDjangoPostgres := len(ctx.App.FindFilesWithContent("**/*.py", djangoPythonRe)) > 0 return p.usesDep(ctx, "psycopg2") || p.usesDep(ctx, "psycopg") || containsDjangoPostgres } func (p *PythonProvider) usesMysql(ctx *generate.GenerateContext) bool { djangoPythonRe := regexp.MustCompile(`django.db.backends.mysql`) containsDjangoMysql := len(ctx.App.FindFilesWithContent("**/*.py", djangoPythonRe)) > 0 return p.usesDep(ctx, "mysqlclient") || containsDjangoMysql } func (p *PythonProvider) addMetadata(ctx *generate.GenerateContext) { hasPoetry := p.hasPoetry(ctx) hasPdm := p.hasPdm(ctx) hasUv := p.hasUv(ctx) pkgManager := "pip" if hasPoetry { pkgManager = "poetry" } else if hasPdm { pkgManager = "pdm" } else if hasUv { pkgManager = "uv" } ctx.Metadata.Set("pythonPackageManager", pkgManager) ctx.Metadata.Set("pythonRuntime", p.getRuntime(ctx)) } func (p *PythonProvider) usesDep(ctx *generate.GenerateContext, dep string) bool { for _, file := range []string{"requirements.txt", "pyproject.toml", "Pipfile"} { if contents, err := ctx.App.ReadFile(file); err == nil { // TODO: Do something better than string comparison if strings.Contains(strings.ToLower(contents), strings.ToLower(dep)) { return true } } } return false } var pipfileFullVersionRegex = regexp.MustCompile(`python_full_version\s*=\s*['"]([0-9.]*)"?`) var pipfileShortVersionRegex = regexp.MustCompile(`python_version\s*=\s*['"]([0-9.]*)"?`) func parseVersionFromPipfile(ctx *generate.GenerateContext) (string, string) { pipfile, err := ctx.App.ReadFile("Pipfile") if err != nil { return "", "" } if matches := pipfileFullVersionRegex.FindStringSubmatch(string(pipfile)); len(matches) > 1 { return matches[1], "python_full_version" } if matches := pipfileShortVersionRegex.FindStringSubmatch(string(pipfile)); len(matches) > 1 { return matches[1], "python_version" } return "", "" } func (p *PythonProvider) hasRequirements(ctx *generate.GenerateContext) bool { return ctx.App.HasFile("requirements.txt") } func (p *PythonProvider) hasPyproject(ctx *generate.GenerateContext) bool { return ctx.App.HasFile("pyproject.toml") } func (p *PythonProvider) hasPipfile(ctx *generate.GenerateContext) bool { return ctx.App.HasFile("Pipfile") } func (p *PythonProvider) hasPoetry(ctx *generate.GenerateContext) bool { return ctx.App.HasFile("poetry.lock") } func (p *PythonProvider) hasPdm(ctx *generate.GenerateContext) bool { return ctx.App.HasFile("pdm.lock") } func (p *PythonProvider) hasUv(ctx *generate.GenerateContext) bool { return ctx.App.HasFile("uv.lock") } func (p *PythonProvider) isFasthtml(ctx *generate.GenerateContext) bool { return p.usesDep(ctx, "python-fasthtml") } func (p *PythonProvider) isFlask(ctx *generate.GenerateContext) bool { return p.usesDep(ctx, "flask") } func (p *PythonProvider) isFastapi(ctx *generate.GenerateContext) bool { return p.usesDep(ctx, "fastapi") } func (p *PythonProvider) getRuntime(ctx *generate.GenerateContext) string { if p.isDjango(ctx) { return "django" } else if p.isFlask(ctx) { return "flask" } else if p.isFastapi(ctx) { return "fastapi" } else if p.isFasthtml(ctx) { return "fasthtml" } return "python" } // Mapping of python dependencies to required apt packages var pythonBuildDepRequirements = map[string][]string{ "pycairo": {"libcairo2-dev"}, } var pythonRuntimeDepRequirements = map[string][]string{ "pycairo": {"libcairo2"}, "pdf2image": {"poppler-utils"}, "pydub": {"ffmpeg"}, "pymovie": {"ffmpeg", "qt5-qmake", "qtbase5-dev", "qtbase5-dev-tools", "qttools5-dev-tools", "libqt5core5a", "python3-pyqt5"}, // Playwright runtime dependencies for Chromium browser // To find the latest list: run `playwright install-deps chromium` and inspect the apt-get install output // Or check: https://github.com/microsoft/playwright/blob/main/packages/playwright-core/browsers.json "playwright": {"libglib2.0-0", "libatk1.0-0", "libatk-bridge2.0-0", "libcups2", "libxkbcommon0", "libatspi2.0-0", "libxcomposite1", "libxdamage1", "libxfixes3", "libxrandr2", "libgbm1", "libcairo2", "libpango-1.0-0", "libasound2", "libnspr4", "libnss3"}, }