pkg/python/python.go (222 lines of code) (raw):

// Copyright 2020 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package python contains Python buildpack library code. package python import ( "fmt" "os" "path/filepath" "strings" "time" "github.com/GoogleCloudPlatform/buildpacks/pkg/ar" "github.com/GoogleCloudPlatform/buildpacks/pkg/buildermetrics" "github.com/GoogleCloudPlatform/buildpacks/pkg/cache" "github.com/GoogleCloudPlatform/buildpacks/pkg/env" gcp "github.com/GoogleCloudPlatform/buildpacks/pkg/gcpbuildpack" "github.com/buildpacks/libcnb/v2" ) const ( dateFormat = time.RFC3339Nano // expirationTime is an arbitrary amount of time of 1 day to refresh the cache layer. expirationTime = time.Duration(time.Hour * 24) pythonVersionKey = "python_version" dependencyHashKey = "dependency_hash" expiryTimestampKey = "expiry_timestamp" cacheName = "pipcache" // RequirementsFilesEnv is an environment variable containg os-path-separator-separated list of paths to pip requirements files. // The requirements files are processed from left to right, with requirements from the next overriding any conflicts from the previous. RequirementsFilesEnv = "GOOGLE_INTERNAL_REQUIREMENTS_FILES" // VendorPipDepsEnv is the envar used to opt using vendored pip dependencies VendorPipDepsEnv = "GOOGLE_VENDOR_PIP_DEPENDENCIES" versionFile = ".python-version" versionKey = "version" versionEnv = "GOOGLE_PYTHON_VERSION" // python37SharedLibDir is the location of the shared Python library when building the python37 runtime. python37SharedLibDir = "/layers/google.python.runtime/python/lib/python3.7/config-3.7m-x86_64-linux-gnu" // python38SharedLibDir is the location of the shared Python library when building the python38 runtime. python38SharedLibDir = "/layers/google.python.runtime/python/lib/python3.8/config-3.8-x86_64-linux-gnu" ) var ( // RequirementsProvides denotes that the buildpack provides requirements.txt in the environment. RequirementsProvides = []libcnb.BuildPlanProvide{{Name: "requirements.txt"}} // RequirementsRequires denotes that the buildpack consumes requirements.txt from the environment. RequirementsRequires = []libcnb.BuildPlanRequire{{Name: "requirements.txt"}} // RequirementsProvidesPlan is a build plan returned by buildpacks that provide requirements.txt. RequirementsProvidesPlan = libcnb.BuildPlan{Provides: RequirementsProvides} // RequirementsProvidesRequiresPlan is a build plan returned by buildpacks that consume requirements.txt. RequirementsProvidesRequiresPlan = libcnb.BuildPlan{Provides: RequirementsProvides, Requires: RequirementsRequires} ) // Version returns the installed version of Python. func Version(ctx *gcp.Context) (string, error) { result, err := ctx.Exec([]string{"python3", "--version"}) if err != nil { return "", err } return strings.TrimSpace(result.Stdout), nil } // RuntimeVersion validate and returns the customer requested Python version by inspecting the // environment variables and .python-version file. func RuntimeVersion(ctx *gcp.Context, dir string) (string, error) { if v := os.Getenv(env.Runtime); v != "" && !strings.HasPrefix(v, "python") { return "*", nil } if v := os.Getenv(versionEnv); v != "" { ctx.Logf("Using Python version from %s: %s", versionEnv, v) return v, nil } if v := os.Getenv(env.RuntimeVersion); v != "" { ctx.Logf("Using Python version from %s: %s", env.RuntimeVersion, v) return v, nil } v, err := versionFromFile(ctx, dir) if err != nil { return "", err } if v != "" { return v, nil } // This will use the highest listed at https://dl.google.com/runtimes/python/version.json. ctx.Logf("Python version not specified, using the latest available version.") return "*", nil } func versionFromFile(ctx *gcp.Context, dir string) (string, error) { vf := filepath.Join(dir, versionFile) versionFileExists, err := ctx.FileExists(vf) if err != nil { return "", err } if versionFileExists { raw, err := ctx.ReadFile(vf) if err != nil { return "", err } v := strings.TrimSpace(string(raw)) if v != "" { ctx.Logf("Using Python version from %s: %s", vf, v) return v, nil } return "", gcp.UserErrorf("%s exists but does not specify a version", vf) } return "", nil } // InstallRequirements installs dependencies from the given requirements files in a virtual env. // It will install the files in order in which they are specified, so that dependencies specified // in later requirements files can override later ones. // // This function is responsible for installing requirements files for all buildpacks that require // it. The buildpacks used to install requirements into separate layers and add the layer path to // PYTHONPATH. However, this caused issues with some packages as it would allow users to // accidentally override some builtin stdlib modules, e.g. typing, enum, etc., and cause both // build-time and run-time failures. func InstallRequirements(ctx *gcp.Context, l *libcnb.Layer, reqs ...string) error { // Defensive check, this should not happen in practice. if len(reqs) == 0 { ctx.Debugf("No requirements.txt to install, clearing layer.") if err := ctx.ClearLayer(l); err != nil { return fmt.Errorf("clearing layer %q: %w", l.Name, err) } return nil } currentPythonVersion, err := Version(ctx) if err != nil { return err } hash, cached, err := cache.HashAndCheck(ctx, l, dependencyHashKey, cache.WithFiles(reqs...), cache.WithStrings(currentPythonVersion)) if err != nil { return err } // Check cache expiration to pick up new versions of dependencies that are not pinned. expired := cacheExpired(ctx, l) if cached && !expired { return nil } if expired { ctx.Debugf("Dependencies cache expired, clearing layer.") } if err := ctx.ClearLayer(l); err != nil { return fmt.Errorf("clearing layer %q: %w", l.Name, err) } ctx.Logf("Installing application dependencies.") cache.Add(ctx, l, dependencyHashKey, hash) // Update the layer metadata. ctx.SetMetadata(l, pythonVersionKey, currentPythonVersion) ctx.SetMetadata(l, expiryTimestampKey, time.Now().Add(expirationTime).Format(dateFormat)) if err := ar.GeneratePythonConfig(ctx); err != nil { return fmt.Errorf("generating Artifact Registry credentials: %w", err) } // History of the logic below: // // pip install --target has several subtle issues: // We cannot use --upgrade: https://github.com/pypa/pip/issues/8799. // We also cannot _not_ use --upgrade, see the requirements_bin_conflict acceptance test. // // Instead, we use Python per-user site-packages (https://www.python.org/dev/peps/pep-0370/) // where we can and virtualenv where we cannot. // // Each requirements file is installed separately to allow the requirements.txt files // to specify conflicting dependencies (e.g. functions-framework pins package A at 1.2.0 but // the user's requirements.txt file pins A at 1.4.0. The user should be able to override // the functions-framework-pinned package). // HACK: For backwards compatibility with Python 3.7 and 3.8 on App Engine and Cloud Functions. virtualEnv := requiresVirtualEnv() if virtualEnv { // --without-pip and --system-site-packages allow us to use `pip` and other packages from the // build image and avoid reinstalling them, saving about 10MB. // TODO(b/140775593): Use virtualenv pip after FTL is no longer used and remove from build image. if _, err := ctx.Exec([]string{"python3", "-m", "venv", "--without-pip", "--system-site-packages", l.Path}); err != nil { return err } if err := copySharedLibs(ctx, l); err != nil { return err } // The VIRTUAL_ENV variable is usually set by the virtual environment's activate script. l.SharedEnvironment.Override("VIRTUAL_ENV", l.Path) // Use the virtual environment python3 for all subsequent commands in this buildpack, for // subsequent buildpacks, l.Path/bin will be added by lifecycle. if err := ctx.Setenv("PATH", filepath.Join(l.Path, "bin")+string(os.PathListSeparator)+os.Getenv("PATH")); err != nil { return err } if err := ctx.Setenv("VIRTUAL_ENV", l.Path); err != nil { return err } } else { l.SharedEnvironment.Default("PYTHONUSERBASE", l.Path) if err := ctx.Setenv("PYTHONUSERBASE", l.Path); err != nil { return err } } for _, req := range reqs { cmd := []string{ "python3", "-m", "pip", "install", "--requirement", req, "--upgrade", "--upgrade-strategy", "only-if-needed", "--no-warn-script-location", // bin is added at run time by lifecycle. "--no-warn-conflicts", // Needed for python37 which allowed users to override dependencies. For newer versions, we do a separate `pip check`. "--force-reinstall", // Some dependencies may be in the build image but not run image. Later requirements.txt should override earlier. "--no-compile", // Prevent default timestamp-based bytecode compilation. Deterministic pycs are generated in a second step below. "--disable-pip-version-check", // If we were going to upgrade pip, we would have done it already in the runtime buildpack. "--no-cache-dir", // We used to save this to a layer, but it made builds slower because it includes http caching of pypi requests. } vendorDir, isVendored := os.LookupEnv(VendorPipDepsEnv) if isVendored { cmd = append(cmd, "--no-index", "--find-links", vendorDir) buildermetrics.GlobalBuilderMetrics().GetCounter(buildermetrics.PipVendorDependenciesCounterID).Increment(1) } if !virtualEnv { cmd = append(cmd, "--user") // Install into user site-packages directory. } if _, err := ctx.Exec(cmd, gcp.WithUserAttribution); err != nil { return err } } // Generate deterministic hash-based pycs (https://www.python.org/dev/peps/pep-0552/). // Use the unchecked version to skip hash validation at run time (for faster startup). result, cerr := ctx.Exec([]string{ "python3", "-m", "compileall", "--invalidation-mode", "unchecked-hash", "-qq", // Do not print any message (matches `pip install` behavior). l.Path, }, gcp.WithUserAttribution) if cerr != nil { if result != nil { if result.ExitCode == 1 { // Ignore file compilation errors (matches `pip install` behavior). return nil } return fmt.Errorf("compileall: %s", result.Combined) } return fmt.Errorf("compileall: %v", cerr) } return nil } // cacheExpired returns true when the cache is past expiration. func cacheExpired(ctx *gcp.Context, l *libcnb.Layer) bool { t := time.Now() expiry := ctx.GetMetadata(l, expiryTimestampKey) if expiry != "" { var err error t, err = time.Parse(dateFormat, expiry) if err != nil { ctx.Debugf("Could not parse expiration date %q, assuming now: %v", expiry, err) } } return !t.After(time.Now()) } // requiresVirtualEnv returns true for runtimes that require a virtual environment to be created before pip install. // We cannot use Python per-user site-packages (https://www.python.org/dev/peps/pep-0370/), // because Python 3.7 and 3.8 on App Engine and Cloud Functions have a virtualenv set up // that disables user site-packages. The base images include a virtual environment pointing to // a directory that is not writeable in the buildpacks world (/env). In order to keep // compatiblity with base image updates, we replace the virtual environment with a writeable one. func requiresVirtualEnv() bool { runtime := os.Getenv(env.Runtime) return runtime == "python37" || runtime == "python38" } // copySharedLibs moves the shared libs from the runtime layer into pip layer. This is required to // support building native extensions in python37 and python38 because virtual env does not copy // the correctly. func copySharedLibs(ctx *gcp.Context, l *libcnb.Layer) error { var oldPath string var newPath string if os.Getenv(env.Runtime) == "python37" { oldPath = python37SharedLibDir newPath = filepath.Join(l.Path, "lib", "python3.7", filepath.Base(oldPath)) } if os.Getenv(env.Runtime) == "python38" { oldPath = python38SharedLibDir newPath = filepath.Join(l.Path, "lib", "python3.8", filepath.Base(oldPath)) } exists, err := ctx.FileExists(oldPath) if err != nil { return gcp.InternalErrorf("finding shared libs in %v: %w", oldPath, err) } if exists { if err := os.Symlink(oldPath, newPath); err != nil { return gcp.InternalErrorf("symlinking shared libs from %v to %v: %w", oldPath, newPath, err) } } return nil }