From 7408ed757ab190cb8635f687fda4439d0c68aa39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=BCller=2C=20Dominik?= Date: Mon, 28 Sep 2026 14:25:35 +0200 Subject: [PATCH 01/14] Detect apps that provide a uv.lock --- bin/detect | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/detect b/bin/detect index 2b5958489..5a4557446 100755 --- a/bin/detect +++ b/bin/detect @@ -13,7 +13,7 @@ BUILD_DIR=$1 BP=$(dirname $(dirname $0)) # Exit early if app is clearly not Python. -if [ ! -f $BUILD_DIR/requirements.txt ] && [ ! -f $BUILD_DIR/setup.py ] && [ ! -f $BUILD_DIR/environment.yml ] && [ ! -f $BUILD_DIR/Pipfile ]; then +if [ ! -f $BUILD_DIR/requirements.txt ] && [ ! -f $BUILD_DIR/setup.py ] && [ ! -f $BUILD_DIR/environment.yml ] && [ ! -f $BUILD_DIR/Pipfile ] && [ ! -f $BUILD_DIR/uv.lock ]; then exit 1 fi From fef0e3d1cd5c31ef7a81a0e90b110e299946a542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=BCller=2C=20Dominik?= Date: Mon, 28 Sep 2026 14:26:49 +0200 Subject: [PATCH 02/14] Install dependencies from uv.lock When an app provides a uv.lock but no requirements.txt, install uv and run 'uv export --frozen' to generate a temporary requirements.txt, just like it is done for Pipfile.lock. The regular pip flow then installs the dependencies, so vendoring, libffi/libmemcache detection and stale package removal keep working for uv apps. Only the default (non-dev) dependencies are exported. The project itself is emitted only when it is a package and is then installed non-editable. --- src/python/supply/mocks_test.go | 14 ++++++ src/python/supply/supply.go | 61 +++++++++++++++++++++++++ src/python/supply/supply_test.go | 77 ++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+) diff --git a/src/python/supply/mocks_test.go b/src/python/supply/mocks_test.go index 0397d0613..8b24cd3f2 100644 --- a/src/python/supply/mocks_test.go +++ b/src/python/supply/mocks_test.go @@ -251,6 +251,20 @@ func (mr *MockInstallerMockRecorder) InstallOnlyVersion(depName, installDir inte return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstallOnlyVersion", reflect.TypeOf((*MockInstaller)(nil).InstallOnlyVersion), depName, installDir) } +// InstallOnlyVersionWithStrip mocks base method. +func (m *MockInstaller) InstallOnlyVersionWithStrip(depName, installDir string, stripComponents int) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InstallOnlyVersionWithStrip", depName, installDir, stripComponents) + ret0, _ := ret[0].(error) + return ret0 +} + +// InstallOnlyVersionWithStrip indicates an expected call of InstallOnlyVersionWithStrip. +func (mr *MockInstallerMockRecorder) InstallOnlyVersionWithStrip(depName, installDir, stripComponents interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstallOnlyVersionWithStrip", reflect.TypeOf((*MockInstaller)(nil).InstallOnlyVersionWithStrip), depName, installDir, stripComponents) +} + // MockCommand is a mock of Command interface. type MockCommand struct { ctrl *gomock.Controller diff --git a/src/python/supply/supply.go b/src/python/supply/supply.go index 613c055d3..79062069d 100644 --- a/src/python/supply/supply.go +++ b/src/python/supply/supply.go @@ -41,6 +41,7 @@ type Manifest interface { type Installer interface { InstallDependency(dep libbuildpack.Dependency, outputDir string) error InstallOnlyVersion(depName, installDir string) error + InstallOnlyVersionWithStrip(depName, installDir string, stripComponents int) error } type Command interface { @@ -107,6 +108,11 @@ func RunPython(s *Supplier) error { return err } + if err := s.InstallUv(); err != nil { + s.Log.Error("Could not install uv: %v", err) + return err + } + if err := s.InstallPipEnv(); err != nil { s.Log.Error("Could not install pipenv: %v", err) return err @@ -452,6 +458,61 @@ func (s *Supplier) InstallPipEnv() error { return s.writeTempRequirementsTxt(outputString) } +// InstallUv generates a temporary requirements.txt from uv.lock, so that the +// dependencies are installed by the regular pip flow (vendoring, libffi and +// libmemcache detection, stale package removal). An existing requirements.txt +// always takes precedence. +func (s *Supplier) InstallUv() error { + uvLockExists, err := libbuildpack.FileExists(filepath.Join(s.Stager.BuildDir(), "uv.lock")) + if err != nil { + return fmt.Errorf("could not check uv.lock existence: %v", err) + } else if !uvLockExists { + return nil + } + + requirementstxtExists, err := libbuildpack.FileExists(filepath.Join(s.Stager.BuildDir(), "requirements.txt")) + if err != nil { + return err + } else if requirementstxtExists { + s.Log.Info("Found requirements.txt, ignoring uv.lock") + return nil + } + + s.Log.Info("Installing uv") + uvDir := filepath.Join("/tmp", "uv") + if err := s.Installer.InstallOnlyVersionWithStrip("uv", uvDir, 1); err != nil { + return err + } + + s.Log.Info("Generating 'requirements.txt' from uv.lock") + cmd := exec.Command(filepath.Join(uvDir, "uv"), uvExportArgs()...) + cmd.Dir = s.Stager.BuildDir() + cmd.Env = append(os.Environ(), "UV_NO_CACHE=1", "UV_PYTHON_DOWNLOADS=never") + cmd.Stderr = indentWriter(os.Stderr) + output, err := s.Command.RunWithOutput(cmd) + if err != nil { + return fmt.Errorf("failed to export uv.lock: %v", err) + } + + return s.writeTempRequirementsTxt(string(output)) +} + +// uvExportArgs exports the locked production dependencies without touching +// the network. The project itself is only emitted if it is a package (i.e. +// it defines a build-system), and is then installed non-editable. +func uvExportArgs() []string { + return []string{ + "export", + "--frozen", + "--format", "requirements-txt", + "--no-dev", + "--no-editable", + "--no-hashes", + "--no-header", + "--no-annotate", + } +} + func pipfileToRequirements(lockFilePath string) (string, error) { var lockFile struct { Meta struct { diff --git a/src/python/supply/supply_test.go b/src/python/supply/supply_test.go index b6533dee4..52524be6d 100644 --- a/src/python/supply/supply_test.go +++ b/src/python/supply/supply_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" "github.com/cloudfoundry/python-buildpack/src/python/supply" @@ -297,6 +298,82 @@ var _ = Describe("Supply", func() { }) }) + Describe("InstallUv", func() { + BeforeEach(func() { + Expect(os.MkdirAll(depDir, 0755)).To(Succeed()) + }) + + Context("when uv.lock does not exist", func() { + It("does not install uv", func() { + Expect(supplier.InstallUv()).To(Succeed()) + Expect(filepath.Join(buildDir, "requirements.txt")).NotTo(BeAnExistingFile()) + }) + }) + + Context("when uv.lock and requirements.txt both exist", func() { + BeforeEach(func() { + Expect(os.WriteFile(filepath.Join(buildDir, "uv.lock"), []byte("version = 1"), 0644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(buildDir, "requirements.txt"), []byte("flask"), 0644)).To(Succeed()) + }) + + It("does not install uv and keeps requirements.txt", func() { + Expect(supplier.InstallUv()).To(Succeed()) + Expect(buffer.String()).To(ContainSubstring("Found requirements.txt, ignoring uv.lock")) + + requirementsContents, err := os.ReadFile(filepath.Join(buildDir, "requirements.txt")) + Expect(err).ToNot(HaveOccurred()) + Expect(string(requirementsContents)).To(Equal("flask")) + }) + }) + + Context("when uv.lock exists but requirements.txt does not exist", func() { + BeforeEach(func() { + Expect(os.WriteFile(filepath.Join(buildDir, "pyproject.toml"), []byte("[project]"), 0644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(buildDir, "uv.lock"), []byte("version = 1"), 0644)).To(Succeed()) + }) + + It("installs uv and generates requirements.txt with uv export", func() { + mockInstaller.EXPECT().InstallOnlyVersionWithStrip("uv", "/tmp/uv", 1) + mockCommand.EXPECT().RunWithOutput(gomock.Any()).DoAndReturn(func(cmd *exec.Cmd) ([]byte, error) { + Expect(cmd.Path).To(Equal("/tmp/uv/uv")) + Expect(cmd.Args[1:]).To(Equal([]string{ + "export", "--frozen", "--format", "requirements-txt", + "--no-dev", "--no-editable", "--no-hashes", "--no-header", "--no-annotate", + })) + Expect(cmd.Dir).To(Equal(buildDir)) + Expect(cmd.Env).To(ContainElements("UV_NO_CACHE=1", "UV_PYTHON_DOWNLOADS=never")) + return []byte("flask==3.1.3\ngunicorn==26.2.0\n"), nil + }) + + Expect(supplier.InstallUv()).To(Succeed()) + Expect(buffer.String()).To(ContainSubstring("Generating 'requirements.txt' from uv.lock")) + + requirementsContents, err := os.ReadFile(filepath.Join(buildDir, "requirements.txt")) + Expect(err).ToNot(HaveOccurred()) + Expect(string(requirementsContents)).To(Equal("flask==3.1.3\ngunicorn==26.2.0\n")) + }) + + Context("when uv cannot be installed", func() { + It("returns the error", func() { + mockInstaller.EXPECT().InstallOnlyVersionWithStrip("uv", "/tmp/uv", 1).Return(errors.New("no versions of uv found")) + + Expect(supplier.InstallUv()).To(MatchError("no versions of uv found")) + Expect(filepath.Join(buildDir, "requirements.txt")).NotTo(BeAnExistingFile()) + }) + }) + + Context("when uv export fails", func() { + It("returns a useful error", func() { + mockInstaller.EXPECT().InstallOnlyVersionWithStrip("uv", "/tmp/uv", 1) + mockCommand.EXPECT().RunWithOutput(gomock.Any()).Return(nil, errors.New("exit status 2")) + + Expect(supplier.InstallUv()).To(MatchError("failed to export uv.lock: exit status 2")) + Expect(filepath.Join(buildDir, "requirements.txt")).NotTo(BeAnExistingFile()) + }) + }) + }) + }) + Describe("HandlePylibmc", func() { BeforeEach(func() { DeferCleanup(os.Setenv, "LIBMEMCACHED", "") From 0fadfdecc764678350de3d2152d30ccb8dd51b54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=BCller=2C=20Dominik?= Date: Mon, 28 Sep 2026 14:27:36 +0200 Subject: [PATCH 03/14] Use .python-version to select the Python version of uv apps uv projects usually pin their interpreter in .python-version instead of runtime.txt. For apps with a uv.lock, translate the first CPython version in that file into runtime.txt. An explicit runtime.txt still wins, and apps without a uv.lock keep ignoring .python-version. --- src/python/supply/supply.go | 65 ++++++++++++++++++++++++++++++++ src/python/supply/supply_test.go | 62 ++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/src/python/supply/supply.go b/src/python/supply/supply.go index 79062069d..8ee927fc3 100644 --- a/src/python/supply/supply.go +++ b/src/python/supply/supply.go @@ -93,6 +93,11 @@ func RunPython(s *Supplier) error { return err } + if err := s.HandleUvPythonVersion(); err != nil { + s.Log.Error("Error checking for .python-version: %v", err) + return err + } + if err := s.HandlePipfile(); err != nil { s.Log.Error("Error checking for Pipfile.lock: %v", err) return err @@ -224,6 +229,66 @@ func (s *Supplier) HandlePipfile() error { return nil } +// HandleUvPythonVersion writes runtime.txt from the .python-version file of a +// uv app, unless the app provides its own runtime.txt. +func (s *Supplier) HandleUvPythonVersion() error { + uvLockExists, err := libbuildpack.FileExists(filepath.Join(s.Stager.BuildDir(), "uv.lock")) + if err != nil { + return err + } + + runtimeExists, err := libbuildpack.FileExists(filepath.Join(s.Stager.DepDir(), "runtime.txt")) + if err != nil { + return err + } + + pythonVersionPath := filepath.Join(s.Stager.BuildDir(), ".python-version") + pythonVersionExists, err := libbuildpack.FileExists(pythonVersionPath) + if err != nil { + return err + } + + if !uvLockExists || runtimeExists || !pythonVersionExists { + return nil + } + + contents, err := os.ReadFile(pythonVersionPath) + if err != nil { + return err + } + + version, ok := parsePythonVersionFile(string(contents)) + if !ok { + s.Log.Warning("Unsupported Python version in .python-version, using the default Python version") + return nil + } + + s.Log.Info("Using Python version %s from .python-version", version) + return os.WriteFile(filepath.Join(s.Stager.DepDir(), "runtime.txt"), []byte(s.formatVersion(version)), 0644) +} + +// parsePythonVersionFile returns the first version in a .python-version +// file. Only CPython versions like "3.12", "3.12.4" or "cpython-3.12" are +// supported. +func parsePythonVersionFile(contents string) (string, bool) { + versionRegex := regexp.MustCompile(`^(?:cpython[-@]?)?(\d+\.\d+(?:\.\d+)?)$`) + + for _, line := range strings.Split(contents, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + match := versionRegex.FindStringSubmatch(line) + if match == nil { + return "", false + } + return match[1], true + } + + return "", false +} + func (s *Supplier) InstallPython() error { var dep libbuildpack.Dependency diff --git a/src/python/supply/supply_test.go b/src/python/supply/supply_test.go index 52524be6d..c89f05740 100644 --- a/src/python/supply/supply_test.go +++ b/src/python/supply/supply_test.go @@ -212,6 +212,68 @@ var _ = Describe("Supply", func() { }) }) + Describe("HandleUvPythonVersion", func() { + BeforeEach(func() { + Expect(os.MkdirAll(depDir, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(buildDir, "uv.lock"), []byte("version = 1"), 0644)).To(Succeed()) + }) + + DescribeTable("creates runtime.txt from .python-version", + func(pythonVersion, expectedRuntime string) { + Expect(os.WriteFile(filepath.Join(buildDir, ".python-version"), []byte(pythonVersion), 0644)).To(Succeed()) + + Expect(supplier.HandleUvPythonVersion()).To(Succeed()) + + runtimeContents, err := os.ReadFile(filepath.Join(depDir, "runtime.txt")) + Expect(err).ToNot(HaveOccurred()) + Expect(string(runtimeContents)).To(Equal(expectedRuntime)) + }, + Entry("minor version", "3.12\n", "python-3.12.x"), + Entry("patch version", "3.12.4\n", "python-3.12.4"), + Entry("implementation prefix", "cpython-3.13\n", "python-3.13.x"), + Entry("comments and multiple versions", "# pinned\n\n3.11\n3.12\n", "python-3.11.x"), + ) + + Context("when .python-version contains an unsupported version", func() { + BeforeEach(func() { + Expect(os.WriteFile(filepath.Join(buildDir, ".python-version"), []byte("pypy@3.10\n"), 0644)).To(Succeed()) + }) + + It("warns and falls back to the default Python version", func() { + Expect(supplier.HandleUvPythonVersion()).To(Succeed()) + Expect(buffer.String()).To(ContainSubstring("Unsupported Python version in .python-version")) + Expect(filepath.Join(depDir, "runtime.txt")).NotTo(BeAnExistingFile()) + }) + }) + + Context("when runtime.txt already exists", func() { + BeforeEach(func() { + Expect(os.WriteFile(filepath.Join(depDir, "runtime.txt"), []byte("python-3.11.x"), 0644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(buildDir, ".python-version"), []byte("3.12\n"), 0644)).To(Succeed()) + }) + + It("keeps runtime.txt", func() { + Expect(supplier.HandleUvPythonVersion()).To(Succeed()) + + runtimeContents, err := os.ReadFile(filepath.Join(depDir, "runtime.txt")) + Expect(err).ToNot(HaveOccurred()) + Expect(string(runtimeContents)).To(Equal("python-3.11.x")) + }) + }) + + Context("when the app does not use uv", func() { + BeforeEach(func() { + Expect(os.Remove(filepath.Join(buildDir, "uv.lock"))).To(Succeed()) + Expect(os.WriteFile(filepath.Join(buildDir, ".python-version"), []byte("3.12\n"), 0644)).To(Succeed()) + }) + + It("ignores .python-version", func() { + Expect(supplier.HandleUvPythonVersion()).To(Succeed()) + Expect(filepath.Join(depDir, "runtime.txt")).NotTo(BeAnExistingFile()) + }) + }) + }) + // Add the expects for what the installFfi function uses expectInstallFfi := func() string { ffiDir := filepath.Join(depDir, "libffi") From 563dcafae61c948ccaf77f6f4475f0d9846ed487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=BCller=2C=20Dominik?= Date: Mon, 28 Sep 2026 14:29:33 +0200 Subject: [PATCH 04/14] Pass package indexes from uv.lock to pip 'uv export' does not emit the indexes configured via [[tool.uv.index]], so pip would silently fall back to PyPI for apps that use a private package index. Read the registry URLs from uv.lock and prepend them as -i/--extra-index-url options, as is already done for Pipfile.lock sources. PyPI stays the primary index when it is used. Indexes configured via PIP_INDEX_URL or PIP_EXTRA_INDEX_URL take precedence, as uv strips credentials from the URLs in uv.lock. --- src/python/supply/supply.go | 55 +++++++++++++++- src/python/supply/supply_test.go | 104 +++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/src/python/supply/supply.go b/src/python/supply/supply.go index 8ee927fc3..71f51f9e5 100644 --- a/src/python/supply/supply.go +++ b/src/python/supply/supply.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "strings" "github.com/cloudfoundry/python-buildpack/src/python/conda" @@ -559,7 +560,59 @@ func (s *Supplier) InstallUv() error { return fmt.Errorf("failed to export uv.lock: %v", err) } - return s.writeTempRequirementsTxt(string(output)) + indexOptions, err := s.uvIndexOptions(filepath.Join(s.Stager.BuildDir(), "uv.lock")) + if err != nil { + return fmt.Errorf("failed to read package indexes from uv.lock: %v", err) + } + + return s.writeTempRequirementsTxt(indexOptions + string(output)) +} + +// uvIndexOptions returns pip index options for the package registries used +// in uv.lock, since 'uv export' does not emit them. PyPI stays the primary +// index when it is used next to other registries. Explicitly configured pip +// indexes take precedence, as they may contain credentials which uv strips +// from the lock file. +func (s *Supplier) uvIndexOptions(lockFilePath string) (string, error) { + if os.Getenv("PIP_INDEX_URL") != "" || os.Getenv("PIP_EXTRA_INDEX_URL") != "" { + s.Log.Info("Using pip index configuration from environment instead of uv.lock") + return "", nil + } + + lockContents, err := os.ReadFile(lockFilePath) + if err != nil { + return "", err + } + + const pypiURL = "https://pypi.org/simple" + var registries []string + usesPypi := false + re := regexp.MustCompile(`(?m)^source\s*=\s*\{.*\bregistry\s*=\s*"([^"]+)"`) + for _, match := range re.FindAllStringSubmatch(string(lockContents), -1) { + registry := strings.TrimSuffix(match[1], "/") + if registry == pypiURL { + usesPypi = true + } else if !slices.Contains(registries, registry) { + registries = append(registries, registry) + } + } + + if len(registries) == 0 { + return "", nil + } + if usesPypi { + registries = append([]string{pypiURL}, registries...) + } + + buf := &bytes.Buffer{} + for i, registry := range registries { + if i == 0 { + fmt.Fprintf(buf, "-i %s\n", registry) + } else { + fmt.Fprintf(buf, "--extra-index-url %s\n", registry) + } + } + return buf.String(), nil } // uvExportArgs exports the locked production dependencies without touching diff --git a/src/python/supply/supply_test.go b/src/python/supply/supply_test.go index c89f05740..36ab791bb 100644 --- a/src/python/supply/supply_test.go +++ b/src/python/supply/supply_test.go @@ -415,6 +415,110 @@ var _ = Describe("Supply", func() { Expect(string(requirementsContents)).To(Equal("flask==3.1.3\ngunicorn==26.2.0\n")) }) + Context("when uv.lock uses package indexes other than PyPI", func() { + BeforeEach(func() { + const uvLock = `version = 1 +requires-python = ">=3.10" + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "internal-lib" +version = "1.0.0" +source = { registry = "https://artifactory.example.com/api/pypi/pypi/simple/" } + +[[package]] +name = "other-lib" +version = "2.0.0" +source = { registry = "https://artifactory.example.com/api/pypi/pypi/simple/" } + +[[package]] +name = "my-app" +version = "0.1.0" +source = { virtual = "." } +` + Expect(os.WriteFile(filepath.Join(buildDir, "uv.lock"), []byte(uvLock), 0644)).To(Succeed()) + mockInstaller.EXPECT().InstallOnlyVersionWithStrip("uv", "/tmp/uv", 1) + mockCommand.EXPECT().RunWithOutput(gomock.Any()).Return([]byte("flask==3.1.3\ninternal-lib==1.0.0\n"), nil) + }) + + It("adds the indexes to requirements.txt", func() { + Expect(supplier.InstallUv()).To(Succeed()) + + requirementsContents, err := os.ReadFile(filepath.Join(buildDir, "requirements.txt")) + Expect(err).ToNot(HaveOccurred()) + Expect(string(requirementsContents)).To(Equal( + "-i https://pypi.org/simple\n" + + "--extra-index-url https://artifactory.example.com/api/pypi/pypi/simple\n" + + "flask==3.1.3\ninternal-lib==1.0.0\n")) + }) + + Context("when PIP_INDEX_URL is set", func() { + BeforeEach(func() { + DeferCleanup(os.Unsetenv, "PIP_INDEX_URL") + Expect(os.Setenv("PIP_INDEX_URL", "https://mirror.example.com/simple")).To(Succeed()) + }) + + It("does not add indexes to requirements.txt", func() { + Expect(supplier.InstallUv()).To(Succeed()) + Expect(buffer.String()).To(ContainSubstring("Using pip index configuration from environment instead of uv.lock")) + + requirementsContents, err := os.ReadFile(filepath.Join(buildDir, "requirements.txt")) + Expect(err).ToNot(HaveOccurred()) + Expect(string(requirementsContents)).To(Equal("flask==3.1.3\ninternal-lib==1.0.0\n")) + }) + }) + }) + + Context("when uv.lock only uses a single private index", func() { + BeforeEach(func() { + const uvLock = `version = 1 + +[[package]] +name = "internal-lib" +version = "1.0.0" +source = { registry = "https://artifactory.example.com/simple" } +` + Expect(os.WriteFile(filepath.Join(buildDir, "uv.lock"), []byte(uvLock), 0644)).To(Succeed()) + mockInstaller.EXPECT().InstallOnlyVersionWithStrip("uv", "/tmp/uv", 1) + mockCommand.EXPECT().RunWithOutput(gomock.Any()).Return([]byte("internal-lib==1.0.0\n"), nil) + }) + + It("uses it as the primary index", func() { + Expect(supplier.InstallUv()).To(Succeed()) + + requirementsContents, err := os.ReadFile(filepath.Join(buildDir, "requirements.txt")) + Expect(err).ToNot(HaveOccurred()) + Expect(string(requirementsContents)).To(Equal("-i https://artifactory.example.com/simple\ninternal-lib==1.0.0\n")) + }) + }) + + Context("when uv.lock only uses PyPI", func() { + BeforeEach(func() { + const uvLock = `version = 1 + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +` + Expect(os.WriteFile(filepath.Join(buildDir, "uv.lock"), []byte(uvLock), 0644)).To(Succeed()) + mockInstaller.EXPECT().InstallOnlyVersionWithStrip("uv", "/tmp/uv", 1) + mockCommand.EXPECT().RunWithOutput(gomock.Any()).Return([]byte("flask==3.1.3\n"), nil) + }) + + It("does not add index options", func() { + Expect(supplier.InstallUv()).To(Succeed()) + + requirementsContents, err := os.ReadFile(filepath.Join(buildDir, "requirements.txt")) + Expect(err).ToNot(HaveOccurred()) + Expect(string(requirementsContents)).To(Equal("flask==3.1.3\n")) + }) + }) + Context("when uv cannot be installed", func() { It("returns the error", func() { mockInstaller.EXPECT().InstallOnlyVersionWithStrip("uv", "/tmp/uv", 1).Return(errors.New("no versions of uv found")) From 13c9b0e3762e9f99de6255e293dfad94eb5e1f9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=BCller=2C=20Dominik?= Date: Mon, 28 Sep 2026 14:30:31 +0200 Subject: [PATCH 05/14] Add integration tests for uv apps Add a uv-managed Flask fixture that pins its Python version via .python-version and has a dev dependency group, and verify that it deploys with the dependencies exported from uv.lock. Also verify that an existing requirements.txt takes precedence over uv.lock. --- fixtures/uv/.gitignore | 2 + fixtures/uv/.python-version | 1 + fixtures/uv/Procfile | 1 + fixtures/uv/pyproject.toml | 13 ++ fixtures/uv/server.py | 18 ++ fixtures/uv/uv.lock | 326 ++++++++++++++++++++++++++++ src/python/integration/init_test.go | 1 + src/python/integration/uv_test.go | 83 +++++++ 8 files changed, 445 insertions(+) create mode 100644 fixtures/uv/.gitignore create mode 100644 fixtures/uv/.python-version create mode 100644 fixtures/uv/Procfile create mode 100644 fixtures/uv/pyproject.toml create mode 100644 fixtures/uv/server.py create mode 100644 fixtures/uv/uv.lock create mode 100644 src/python/integration/uv_test.go diff --git a/fixtures/uv/.gitignore b/fixtures/uv/.gitignore new file mode 100644 index 000000000..2419db02f --- /dev/null +++ b/fixtures/uv/.gitignore @@ -0,0 +1,2 @@ +*.pyc +.venv diff --git a/fixtures/uv/.python-version b/fixtures/uv/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/fixtures/uv/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/fixtures/uv/Procfile b/fixtures/uv/Procfile new file mode 100644 index 000000000..caf672282 --- /dev/null +++ b/fixtures/uv/Procfile @@ -0,0 +1 @@ +web: gunicorn server:app diff --git a/fixtures/uv/pyproject.toml b/fixtures/uv/pyproject.toml new file mode 100644 index 000000000..74224d67b --- /dev/null +++ b/fixtures/uv/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "uv-app" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [ + "flask", + "gunicorn", +] + +[dependency-groups] +dev = [ + "pytest", +] diff --git a/fixtures/uv/server.py b/fixtures/uv/server.py new file mode 100644 index 000000000..46f26a92c --- /dev/null +++ b/fixtures/uv/server.py @@ -0,0 +1,18 @@ +import os + +from flask import Flask + +app = Flask(__name__) + +port = int(os.getenv('VCAP_APP_PORT', 8080)) + + +@app.route("/") +def hello(): + return "Hello, World!" + + +app.debug = True + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=port) diff --git a/fixtures/uv/uv.lock b/fixtures/uv/uv.lock new file mode 100644 index 000000000..3839d73ef --- /dev/null +++ b/fixtures/uv/uv.lock @@ -0,0 +1,326 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "gunicorn" +version = "26.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/8a/e4ef6ee11701b6cd64702848415ffb69eeff85cb388a3c6c7fe86f22f3f8/gunicorn-26.2.0.tar.gz", hash = "sha256:62b864895d9ebff0b2f9867ba04fe811c93121596540830c9c916d0769668447", size = 787921, upload-time = "2026-08-24T15:05:59.3Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl", hash = "sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3", size = 228389, upload-time = "2026-08-24T15:05:57.67Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "uv-app" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "flask" }, + { name = "gunicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "flask" }, + { name = "gunicorn" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest" }] + +[[package]] +name = "werkzeug" +version = "3.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz", hash = "sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060", size = 940188, upload-time = "2026-09-27T18:33:41.637Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl", hash = "sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab", size = 228700, upload-time = "2026-09-27T18:33:39.685Z" }, +] diff --git a/src/python/integration/init_test.go b/src/python/integration/init_test.go index 9f9f19159..ecf7dcadf 100644 --- a/src/python/integration/init_test.go +++ b/src/python/integration/init_test.go @@ -104,6 +104,7 @@ func TestIntegration(t *testing.T) { suite("Miscellaneous", testMiscellaneous(platform, fixtures)) suite("Django", testDjango(platform, fixtures)) suite("Pipenv", testPipenv(platform, fixtures)) + suite("Uv", testUv(platform, fixtures)) suite("Override", testOverride(platform, fixtures)) suite("Multibuildpack", testMultibuildpack(platform, fixtures)) suite("Sealights", testSealights(platform, fixtures)) diff --git a/src/python/integration/uv_test.go b/src/python/integration/uv_test.go new file mode 100644 index 000000000..4ab846c7e --- /dev/null +++ b/src/python/integration/uv_test.go @@ -0,0 +1,83 @@ +package integration_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/cloudfoundry/switchblade" + "github.com/sclevine/spec" + + . "github.com/cloudfoundry/switchblade/matchers" + . "github.com/onsi/gomega" +) + +func testUv(platform switchblade.Platform, fixtures string) func(*testing.T, spec.G, spec.S) { + return func(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + Eventually = NewWithT(t).Eventually + + name string + source string + ) + + it.Before(func() { + var err error + name, err = switchblade.RandomName() + Expect(err).NotTo(HaveOccurred()) + + source, err = switchblade.Source(filepath.Join(fixtures, "uv")) + Expect(err).NotTo(HaveOccurred()) + }) + + it.After(func() { + Expect(platform.Delete.Execute(name)).To(Succeed()) + Expect(os.RemoveAll(source)).To(Succeed()) + }) + + context("when pushing an app that has a uv.lock and no requirements.txt", func() { + it("successfully deploys, generates a requirements.txt, and installs the packages", func() { + deployment, logs, err := platform.Deploy. + WithBuildpacks("python_buildpack"). + Execute(name, source) + Expect(err).NotTo(HaveOccurred()) + + Eventually(deployment).Should(Serve(ContainSubstring("Hello, World!"))) + + Expect(logs.String()).To(SatisfyAll( + ContainSubstring("Using Python version 3.12 from .python-version"), + MatchRegexp(`Installing python 3\.12\.\d+`), + ContainSubstring("Installing uv"), + ContainSubstring("Generating 'requirements.txt' from uv.lock"), + ContainSubstring("Running Pip Install (Unvendored)"), + ContainSubstring("Collecting flask==3.1.3"), + ContainSubstring("Collecting gunicorn==26.2.0"), + Not(ContainSubstring("pytest")), + )) + }) + }) + + context("when pushing an app that has a uv.lock and a requirements.txt", func() { + it.Before(func() { + CreateRequirementsTxtFile(Expect, source, "requirements.txt", + "Flask", "Jinja2", "MarkupSafe", "Werkzeug", "gunicorn", "itsdangerous") + }) + + it("installs the packages from requirements.txt", func() { + deployment, logs, err := platform.Deploy. + WithBuildpacks("python_buildpack"). + Execute(name, source) + Expect(err).NotTo(HaveOccurred()) + + Eventually(deployment).Should(Serve(ContainSubstring("Hello, World!"))) + + Expect(logs.String()).To(SatisfyAll( + ContainSubstring("Found requirements.txt, ignoring uv.lock"), + Not(ContainSubstring("Installing uv")), + ContainSubstring("Collecting Flask"), + )) + }) + }) + } +} From eb04932f6b4058370183a122b7338643fc4352dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=BCller=2C=20Dominik?= Date: Mon, 28 Sep 2026 14:49:21 +0200 Subject: [PATCH 06/14] Document uv support in README --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index eda068e0b..b9771473e 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,22 @@ This buildpack supports running Django and Flask apps. Official buildpack documentation can be found at [python buildpack docs](http://docs.cloudfoundry.org/buildpacks/python/index.html). +### uv + +Apps managed with [uv](https://docs.astral.sh/uv/) are supported by pushing the `pyproject.toml` together with a `uv.lock`: + +* During staging the buildpack runs `uv export --frozen` to generate a temporary `requirements.txt` from `uv.lock`, which is then installed with pip. Keep `uv.lock` up to date (`uv lock`) before pushing, as it is used as is. +* Only the default dependencies are installed, dependency groups such as `dev` are skipped. If the project defines a `[build-system]`, the project itself is installed as well. +* The Python version is taken from `.python-version` (e.g. `3.12`, as written by `uv python pin`), unless a `runtime.txt` exists. +* Package indexes configured via `[[tool.uv.index]]` are passed on to pip. Credentials are not part of `uv.lock`, so for authenticated indexes set `PIP_INDEX_URL` / `PIP_EXTRA_INDEX_URL` instead, which then take precedence. +* If the app also contains a `requirements.txt`, the `uv.lock` is ignored. +* For vendored (offline) installs, download the wheels for the target platform into the `vendor` directory. Write the exported file outside the app, so that it does not take precedence over `uv.lock`: + + ```bash + uv export --frozen --no-dev --no-hashes -o /tmp/requirements.txt + pip download -r /tmp/requirements.txt -d vendor --only-binary=:all: --platform manylinux2014_x86_64 --python-version 3.12 + ``` + ## Adding new dependencies If you want to add a new dependency to the buildpack, please add it to the [config.yml](https://github.com/cloudfoundry/buildpacks-ci/blob/5a63d13df09f83d5dff7c71d0a12c3e2dc798d39/pipelines/dependency-builds/config.yml#L272) file. For example, if you want to add a new version of Python, add an entry like the following: From 2494acf85b00cee71415daf34a327955b5bcb385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=BCller=2C=20Dominik?= Date: Mon, 28 Sep 2026 15:16:31 +0200 Subject: [PATCH 07/14] Match package names in requirements case-insensitively Package names are normalized as defined in PEP 503 before comparing them. Generated requirements files, such as those exported by uv, use lowercase names, so packages like 'PyNaCl' or 'pyOpenSSL' were not detected and libffi was not installed for them. --- src/python/requirements/requirements.go | 14 +++++++--- src/python/requirements/requirements_test.go | 27 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/python/requirements/requirements.go b/src/python/requirements/requirements.go index e40c58c45..4e829b34a 100644 --- a/src/python/requirements/requirements.go +++ b/src/python/requirements/requirements.go @@ -28,9 +28,11 @@ func (m Reqs) FindAnyPackage(buildDir string, searchedPackages ...string) (bool, return false, err } - for _, searchedPackage := range searchedPackages { - if containsPackage(reqPackages, searchedPackage) { - return true, nil + for _, reqPackage := range reqPackages { + for _, searchedPackage := range searchedPackages { + if normalizePackageName(reqPackage) == normalizePackageName(searchedPackage) { + return true, nil + } } } } @@ -81,6 +83,12 @@ func containsPackage(packages []string, searchedPackage string) bool { return false } +// normalizePackageName normalizes a package name as defined in PEP 503, since +// e.g. 'PyNaCl' may be written as 'pynacl' in generated requirements files. +func normalizePackageName(name string) string { + return strings.ToLower(regexp.MustCompile(`[-_.]+`).ReplaceAllString(name, "-")) +} + func packageIsExcluded(excludedPackages []string, packageFullName string) bool { regex := regexp.MustCompile(requirementsParserRegex) diff --git a/src/python/requirements/requirements_test.go b/src/python/requirements/requirements_test.go index 24be38d56..5d3a5b7ef 100644 --- a/src/python/requirements/requirements_test.go +++ b/src/python/requirements/requirements_test.go @@ -60,6 +60,33 @@ package6[test]==6.0.0 }) }) + Context("package names are written differently", func() { + BeforeEach(func() { + Expect(os.WriteFile(filepath.Join(tempDir, "requirements.txt"), []byte(`pynacl==1.5.0 +argon2_cffi==23.1.0 +Django==5.2 +`), 0644)).To(Succeed()) + }) + + It("matches the normalized package names", func() { + exists, err := req.FindAnyPackage(tempDir, "PyNaCl") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + + exists, err = req.FindAnyPackage(tempDir, "argon2-cffi") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + + exists, err = req.FindAnyPackage(tempDir, "django") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + + exists, err = req.FindAnyPackage(tempDir, "cffi") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + }) + Context("multiple requirements.txt files", func() { Context("packages are in a recursive requirements.txt file", func() { BeforeEach(func() { From 9afebfac872efba520a55db1666be41a2997f485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=BCller=2C=20Dominik?= Date: Mon, 28 Sep 2026 15:17:16 +0200 Subject: [PATCH 08/14] Run collectstatic for apps with a generated requirements.txt For apps using Pipfile.lock or uv.lock, supply generates a temporary requirements.txt and deletes it at the end of the supply phase. Finalize then no longer found Django in it and silently skipped collectstatic. Keep a copy of the generated requirements.txt in the deps dir and let finalize check it as well. --- src/python/finalize/finalize.go | 17 ++++++++++--- src/python/finalize/finalize_test.go | 18 ++++++++++++-- src/python/supply/supply.go | 23 +++++++++++++---- src/python/supply/supply_test.go | 37 ++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 11 deletions(-) diff --git a/src/python/finalize/finalize.go b/src/python/finalize/finalize.go index 44fdf97c5..9bada3ce2 100644 --- a/src/python/finalize/finalize.go +++ b/src/python/finalize/finalize.go @@ -75,10 +75,19 @@ func (f *Finalizer) HandleCollectstatic() error { return nil } - exists, err := f.Requirements.FindAnyPackage(f.Stager.BuildDir(), "django", "Django") - if err != nil { - f.Log.Debug("Error during FindAnyPackage, skipping collectstatic") - return err + // Supply moves a generated requirements.txt (e.g. from Pipfile.lock or + // uv.lock) from the app directory to the deps dir. + var exists bool + for _, dir := range []string{f.Stager.BuildDir(), f.Stager.DepDir()} { + found, err := f.Requirements.FindAnyPackage(dir, "django") + if err != nil { + f.Log.Debug("Error during FindAnyPackage, skipping collectstatic") + return err + } + if found { + exists = true + break + } } if !exists { diff --git a/src/python/finalize/finalize_test.go b/src/python/finalize/finalize_test.go index bb4d8a41b..024b8936f 100644 --- a/src/python/finalize/finalize_test.go +++ b/src/python/finalize/finalize_test.go @@ -87,7 +87,7 @@ var _ = Describe("Finalize", func() { }) Context("app uses Django", func() { BeforeEach(func() { - mockRequirements.EXPECT().FindAnyPackage(buildDir, "django", "Django").Return(true, nil) + mockRequirements.EXPECT().FindAnyPackage(buildDir, "django").Return(true, nil) mockManagePyFinder.EXPECT().FindManagePy(buildDir).Return("/foo/bar/manage.py", nil) }) @@ -112,13 +112,27 @@ var _ = Describe("Finalize", func() { Context("app does not use Django", func() { BeforeEach(func() { - mockRequirements.EXPECT().FindAnyPackage(buildDir, "django", "Django").Return(false, nil) + mockRequirements.EXPECT().FindAnyPackage(buildDir, "django").Return(false, nil) + mockRequirements.EXPECT().FindAnyPackage(filepath.Join(depsDir, depsIdx), "django").Return(false, nil) }) It("does not run anything", func() { Expect(finalizer.HandleCollectstatic()).To(Succeed()) }) }) + + Context("Django is only in the requirements.txt generated during supply", func() { + BeforeEach(func() { + mockRequirements.EXPECT().FindAnyPackage(buildDir, "django").Return(false, nil) + mockRequirements.EXPECT().FindAnyPackage(filepath.Join(depsDir, depsIdx), "django").Return(true, nil) + mockManagePyFinder.EXPECT().FindManagePy(buildDir).Return("/foo/bar/manage.py", nil) + }) + + It("runs collectstatic", func() { + mockCommand.EXPECT().Execute(buildDir, gomock.Any(), gomock.Any(), "python", "/foo/bar/manage.py", "collectstatic", "--noinput", "--traceback").Return(nil) + Expect(finalizer.HandleCollectstatic()).To(Succeed()) + }) + }) }) }) diff --git a/src/python/supply/supply.go b/src/python/supply/supply.go index 71f51f9e5..0d6ef710c 100644 --- a/src/python/supply/supply.go +++ b/src/python/supply/supply.go @@ -180,11 +180,9 @@ func RunPython(s *Supplier) error { s.Log.Debug("Size of pip cache dir: %s", cacheDirSize) } - if s.removeRequirementsText { - if err := os.Remove(filepath.Join(s.Stager.BuildDir(), "requirements.txt")); err != nil { - s.Log.Error("Unable to clean up app directory: %s", err.Error()) - return err - } + if err := s.MoveGeneratedRequirementsTxt(); err != nil { + s.Log.Error("Unable to clean up app directory: %s", err.Error()) + return err } dirSnapshot.Diff() @@ -1107,6 +1105,21 @@ func (s *Supplier) writeTempRequirementsTxt(content string) error { return os.WriteFile(filepath.Join(s.Stager.BuildDir(), "requirements.txt"), []byte(content), 0644) } +// MoveGeneratedRequirementsTxt removes a generated requirements.txt from the +// app directory. A copy is kept in the deps dir, so that finalize can still +// detect packages like Django. +func (s *Supplier) MoveGeneratedRequirementsTxt() error { + if !s.removeRequirementsText { + return nil + } + + requirementsPath := filepath.Join(s.Stager.BuildDir(), "requirements.txt") + if err := libbuildpack.CopyFile(requirementsPath, filepath.Join(s.Stager.DepDir(), "requirements.txt")); err != nil { + return err + } + return os.Remove(requirementsPath) +} + func (s *Supplier) hasBuildOptions() bool { helpCommand := append(pipCommand(), "install", "--no-build-isolation", "-h") err := s.Command.Execute(s.Stager.BuildDir(), nil, nil, helpCommand[0], helpCommand[1:]...) diff --git a/src/python/supply/supply_test.go b/src/python/supply/supply_test.go index 36ab791bb..f32342275 100644 --- a/src/python/supply/supply_test.go +++ b/src/python/supply/supply_test.go @@ -540,6 +540,43 @@ source = { registry = "https://pypi.org/simple" } }) }) + Describe("MoveGeneratedRequirementsTxt", func() { + BeforeEach(func() { + Expect(os.MkdirAll(depDir, 0755)).To(Succeed()) + }) + + Context("when requirements.txt was generated", func() { + BeforeEach(func() { + const lockFileContent string = `{"_meta":{"sources":[]},"default":{"django":{"version":"==5.2"}}}` + Expect(os.WriteFile(filepath.Join(buildDir, "Pipfile"), []byte("some content"), 0644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(buildDir, "Pipfile.lock"), []byte(lockFileContent), 0644)).To(Succeed()) + Expect(supplier.InstallPipEnv()).To(Succeed()) + }) + + It("moves it from the app directory to the deps dir", func() { + Expect(supplier.MoveGeneratedRequirementsTxt()).To(Succeed()) + + Expect(filepath.Join(buildDir, "requirements.txt")).NotTo(BeAnExistingFile()) + requirementsContents, err := os.ReadFile(filepath.Join(depDir, "requirements.txt")) + Expect(err).ToNot(HaveOccurred()) + Expect(string(requirementsContents)).To(Equal("django==5.2\n")) + }) + }) + + Context("when requirements.txt was provided by the app", func() { + BeforeEach(func() { + Expect(os.WriteFile(filepath.Join(buildDir, "requirements.txt"), []byte("django"), 0644)).To(Succeed()) + }) + + It("keeps it in the app directory", func() { + Expect(supplier.MoveGeneratedRequirementsTxt()).To(Succeed()) + + Expect(filepath.Join(buildDir, "requirements.txt")).To(BeARegularFile()) + Expect(filepath.Join(depDir, "requirements.txt")).NotTo(BeAnExistingFile()) + }) + }) + }) + Describe("HandlePylibmc", func() { BeforeEach(func() { DeferCleanup(os.Setenv, "LIBMEMCACHED", "") From 7837d6720d0bd32223340f4ade6d1c0f3a229765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=BCller=2C=20Dominik?= Date: Mon, 28 Sep 2026 15:20:00 +0200 Subject: [PATCH 09/14] Install uv apps with uv sync Install the packages of uv apps with 'uv sync --frozen' directly into the Python installation of the buildpack instead of exporting them for pip. uv verifies every package against the hashes in uv.lock and supports all of uv's features, like workspaces, [tool.uv.sources] and index configuration including credentials via UV_INDEX_* variables. Reading the package indexes from uv.lock for pip is no longer needed. uv.lock is still exported to a temporary requirements.txt, now with hashes, so that packages which need libffi, libmemcached or collectstatic are detected as before. uv uses the system certificate store like pip does, and its cache is kept in the app cache. Vendored uv apps are still installed with pip. --- src/python/supply/mocks_test.go | 14 +++ src/python/supply/supply.go | 117 ++++++++++++------------ src/python/supply/supply_test.go | 148 ++++++++++--------------------- 3 files changed, 122 insertions(+), 157 deletions(-) diff --git a/src/python/supply/mocks_test.go b/src/python/supply/mocks_test.go index 8b24cd3f2..1cd2d410e 100644 --- a/src/python/supply/mocks_test.go +++ b/src/python/supply/mocks_test.go @@ -327,6 +327,20 @@ func (mr *MockCommandMockRecorder) Output(dir, program interface{}, args ...inte return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Output", reflect.TypeOf((*MockCommand)(nil).Output), varargs...) } +// Run mocks base method. +func (m *MockCommand) Run(cmd *exec.Cmd) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Run", cmd) + ret0, _ := ret[0].(error) + return ret0 +} + +// Run indicates an expected call of Run. +func (mr *MockCommandMockRecorder) Run(cmd interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Run", reflect.TypeOf((*MockCommand)(nil).Run), cmd) +} + // RunWithOutput mocks base method. func (m *MockCommand) RunWithOutput(cmd *exec.Cmd) ([]byte, error) { m.ctrl.T.Helper() diff --git a/src/python/supply/supply.go b/src/python/supply/supply.go index 0d6ef710c..79f101905 100644 --- a/src/python/supply/supply.go +++ b/src/python/supply/supply.go @@ -8,7 +8,6 @@ import ( "os" "path/filepath" "regexp" - "slices" "strings" "github.com/cloudfoundry/python-buildpack/src/python/conda" @@ -23,6 +22,8 @@ import ( const EnvPipVersion = "BP_PIP_VERSION" +var uvDir = filepath.Join("/tmp", "uv") + type Stager interface { BuildDir() string CacheDir() string @@ -48,6 +49,7 @@ type Installer interface { type Command interface { Execute(dir string, stdout io.Writer, stderr io.Writer, program string, args ...string) error Output(dir string, program string, args ...string) (string, error) + Run(cmd *exec.Cmd) error RunWithOutput(cmd *exec.Cmd) ([]byte, error) } @@ -66,6 +68,7 @@ type Supplier struct { Logfile *os.File HasNltkData bool removeRequirementsText bool + usesUv bool Requirements Reqs } @@ -149,7 +152,12 @@ func RunPython(s *Supplier) error { return fmt.Errorf("could not check vendor existence: %v", err) } - if vendored { + if s.usesUv && !vendored { + if err := s.RunUvSync(); err != nil { + s.Log.Error("Could not install packages with uv: %v", err) + return err + } + } else if vendored { if err := s.RunPipVendored(); err != nil { s.Log.Error("Could not install vendored pip packages: %v", err) return err @@ -522,10 +530,11 @@ func (s *Supplier) InstallPipEnv() error { return s.writeTempRequirementsTxt(outputString) } -// InstallUv generates a temporary requirements.txt from uv.lock, so that the -// dependencies are installed by the regular pip flow (vendoring, libffi and -// libmemcache detection, stale package removal). An existing requirements.txt -// always takes precedence. +// InstallUv installs uv for apps with a uv.lock and exports the locked +// packages into a temporary requirements.txt, which is used to detect +// packages that need special handling (e.g. libffi or Django). The packages +// themselves are installed by RunUvSync. An existing requirements.txt always +// takes precedence. func (s *Supplier) InstallUv() error { uvLockExists, err := libbuildpack.FileExists(filepath.Join(s.Stager.BuildDir(), "uv.lock")) if err != nil { @@ -543,79 +552,47 @@ func (s *Supplier) InstallUv() error { } s.Log.Info("Installing uv") - uvDir := filepath.Join("/tmp", "uv") if err := s.Installer.InstallOnlyVersionWithStrip("uv", uvDir, 1); err != nil { return err } + s.usesUv = true s.Log.Info("Generating 'requirements.txt' from uv.lock") cmd := exec.Command(filepath.Join(uvDir, "uv"), uvExportArgs()...) cmd.Dir = s.Stager.BuildDir() - cmd.Env = append(os.Environ(), "UV_NO_CACHE=1", "UV_PYTHON_DOWNLOADS=never") + cmd.Env = s.uvEnv() cmd.Stderr = indentWriter(os.Stderr) output, err := s.Command.RunWithOutput(cmd) if err != nil { return fmt.Errorf("failed to export uv.lock: %v", err) } - indexOptions, err := s.uvIndexOptions(filepath.Join(s.Stager.BuildDir(), "uv.lock")) - if err != nil { - return fmt.Errorf("failed to read package indexes from uv.lock: %v", err) - } - - return s.writeTempRequirementsTxt(indexOptions + string(output)) + return s.writeTempRequirementsTxt(string(output)) } -// uvIndexOptions returns pip index options for the package registries used -// in uv.lock, since 'uv export' does not emit them. PyPI stays the primary -// index when it is used next to other registries. Explicitly configured pip -// indexes take precedence, as they may contain credentials which uv strips -// from the lock file. -func (s *Supplier) uvIndexOptions(lockFilePath string) (string, error) { - if os.Getenv("PIP_INDEX_URL") != "" || os.Getenv("PIP_EXTRA_INDEX_URL") != "" { - s.Log.Info("Using pip index configuration from environment instead of uv.lock") - return "", nil - } +// RunUvSync installs the locked packages with 'uv sync' directly into the +// Python installation, verifying them against the hashes in uv.lock. +func (s *Supplier) RunUvSync() error { + s.Log.BeginStep("Running uv sync") - lockContents, err := os.ReadFile(lockFilePath) - if err != nil { - return "", err - } - - const pypiURL = "https://pypi.org/simple" - var registries []string - usesPypi := false - re := regexp.MustCompile(`(?m)^source\s*=\s*\{.*\bregistry\s*=\s*"([^"]+)"`) - for _, match := range re.FindAllStringSubmatch(string(lockContents), -1) { - registry := strings.TrimSuffix(match[1], "/") - if registry == pypiURL { - usesPypi = true - } else if !slices.Contains(registries, registry) { - registries = append(registries, registry) - } - } + args := uvSyncArgs() + s.Log.Info("uv %s", strings.Join(args, " ")) - if len(registries) == 0 { - return "", nil - } - if usesPypi { - registries = append([]string{pypiURL}, registries...) + cmd := exec.Command(filepath.Join(uvDir, "uv"), args...) + cmd.Dir = s.Stager.BuildDir() + cmd.Env = append(s.uvEnv(), "UV_PROJECT_ENVIRONMENT="+filepath.Join(s.Stager.DepDir(), "python")) + cmd.Stdout = indentWriter(os.Stdout) + cmd.Stderr = indentWriter(os.Stderr) + if err := s.Command.Run(cmd); err != nil { + return fmt.Errorf("could not run uv sync: %v", err) } - buf := &bytes.Buffer{} - for i, registry := range registries { - if i == 0 { - fmt.Fprintf(buf, "-i %s\n", registry) - } else { - fmt.Fprintf(buf, "--extra-index-url %s\n", registry) - } - } - return buf.String(), nil + return s.Stager.LinkDirectoryInDepDir(filepath.Join(s.Stager.DepDir(), "python", "bin"), "bin") } // uvExportArgs exports the locked production dependencies without touching // the network. The project itself is only emitted if it is a package (i.e. -// it defines a build-system), and is then installed non-editable. +// it defines a build-system). func uvExportArgs() []string { return []string{ "export", @@ -623,12 +600,38 @@ func uvExportArgs() []string { "--format", "requirements-txt", "--no-dev", "--no-editable", - "--no-hashes", "--no-header", "--no-annotate", } } +// uvSyncArgs installs the same packages as exported by uvExportArgs. +// '--inexact' keeps packages that are not part of uv.lock, like pip. +func uvSyncArgs() []string { + return []string{ + "sync", + "--frozen", + "--no-dev", + "--no-editable", + "--inexact", + } +} + +// uvEnv makes uv use the Python installed by the buildpack, trust the same +// certificates as pip, and keep its cache in the app cache. +func (s *Supplier) uvEnv() []string { + env := append(os.Environ(), + "UV_CACHE_DIR="+filepath.Join(s.Stager.CacheDir(), "uv_cache"), + "UV_LINK_MODE=copy", + "UV_PYTHON="+filepath.Join(s.Stager.DepDir(), "python", "bin", "python"), + "UV_PYTHON_DOWNLOADS=never", + ) + if os.Getenv("UV_SYSTEM_CERTS") == "" { + env = append(env, "UV_SYSTEM_CERTS=1") + } + return env +} + func pipfileToRequirements(lockFilePath string) (string, error) { var lockFile struct { Meta struct { diff --git a/src/python/supply/supply_test.go b/src/python/supply/supply_test.go index f32342275..2ec7ae247 100644 --- a/src/python/supply/supply_test.go +++ b/src/python/supply/supply_test.go @@ -394,17 +394,23 @@ var _ = Describe("Supply", func() { Expect(os.WriteFile(filepath.Join(buildDir, "uv.lock"), []byte("version = 1"), 0644)).To(Succeed()) }) - It("installs uv and generates requirements.txt with uv export", func() { + It("installs uv and generates requirements.txt with hashes using uv export", func() { + const exported = "flask==3.1.3 \\\n --hash=sha256:4eda\ngunicorn==26.2.0 \\\n --hash=sha256:17ef\n" mockInstaller.EXPECT().InstallOnlyVersionWithStrip("uv", "/tmp/uv", 1) mockCommand.EXPECT().RunWithOutput(gomock.Any()).DoAndReturn(func(cmd *exec.Cmd) ([]byte, error) { Expect(cmd.Path).To(Equal("/tmp/uv/uv")) Expect(cmd.Args[1:]).To(Equal([]string{ "export", "--frozen", "--format", "requirements-txt", - "--no-dev", "--no-editable", "--no-hashes", "--no-header", "--no-annotate", + "--no-dev", "--no-editable", "--no-header", "--no-annotate", })) Expect(cmd.Dir).To(Equal(buildDir)) - Expect(cmd.Env).To(ContainElements("UV_NO_CACHE=1", "UV_PYTHON_DOWNLOADS=never")) - return []byte("flask==3.1.3\ngunicorn==26.2.0\n"), nil + Expect(cmd.Env).To(ContainElements( + "UV_CACHE_DIR="+filepath.Join(cacheDir, "uv_cache"), + "UV_PYTHON="+filepath.Join(depDir, "python", "bin", "python"), + "UV_PYTHON_DOWNLOADS=never", + "UV_SYSTEM_CERTS=1", + )) + return []byte(exported), nil }) Expect(supplier.InstallUv()).To(Succeed()) @@ -412,110 +418,24 @@ var _ = Describe("Supply", func() { requirementsContents, err := os.ReadFile(filepath.Join(buildDir, "requirements.txt")) Expect(err).ToNot(HaveOccurred()) - Expect(string(requirementsContents)).To(Equal("flask==3.1.3\ngunicorn==26.2.0\n")) + Expect(string(requirementsContents)).To(Equal(exported)) }) - Context("when uv.lock uses package indexes other than PyPI", func() { + Context("when UV_SYSTEM_CERTS is set by the app", func() { BeforeEach(func() { - const uvLock = `version = 1 -requires-python = ">=3.10" - -[[package]] -name = "flask" -version = "3.1.3" -source = { registry = "https://pypi.org/simple" } - -[[package]] -name = "internal-lib" -version = "1.0.0" -source = { registry = "https://artifactory.example.com/api/pypi/pypi/simple/" } - -[[package]] -name = "other-lib" -version = "2.0.0" -source = { registry = "https://artifactory.example.com/api/pypi/pypi/simple/" } - -[[package]] -name = "my-app" -version = "0.1.0" -source = { virtual = "." } -` - Expect(os.WriteFile(filepath.Join(buildDir, "uv.lock"), []byte(uvLock), 0644)).To(Succeed()) - mockInstaller.EXPECT().InstallOnlyVersionWithStrip("uv", "/tmp/uv", 1) - mockCommand.EXPECT().RunWithOutput(gomock.Any()).Return([]byte("flask==3.1.3\ninternal-lib==1.0.0\n"), nil) - }) - - It("adds the indexes to requirements.txt", func() { - Expect(supplier.InstallUv()).To(Succeed()) - - requirementsContents, err := os.ReadFile(filepath.Join(buildDir, "requirements.txt")) - Expect(err).ToNot(HaveOccurred()) - Expect(string(requirementsContents)).To(Equal( - "-i https://pypi.org/simple\n" + - "--extra-index-url https://artifactory.example.com/api/pypi/pypi/simple\n" + - "flask==3.1.3\ninternal-lib==1.0.0\n")) - }) - - Context("when PIP_INDEX_URL is set", func() { - BeforeEach(func() { - DeferCleanup(os.Unsetenv, "PIP_INDEX_URL") - Expect(os.Setenv("PIP_INDEX_URL", "https://mirror.example.com/simple")).To(Succeed()) - }) - - It("does not add indexes to requirements.txt", func() { - Expect(supplier.InstallUv()).To(Succeed()) - Expect(buffer.String()).To(ContainSubstring("Using pip index configuration from environment instead of uv.lock")) - - requirementsContents, err := os.ReadFile(filepath.Join(buildDir, "requirements.txt")) - Expect(err).ToNot(HaveOccurred()) - Expect(string(requirementsContents)).To(Equal("flask==3.1.3\ninternal-lib==1.0.0\n")) - }) - }) - }) - - Context("when uv.lock only uses a single private index", func() { - BeforeEach(func() { - const uvLock = `version = 1 - -[[package]] -name = "internal-lib" -version = "1.0.0" -source = { registry = "https://artifactory.example.com/simple" } -` - Expect(os.WriteFile(filepath.Join(buildDir, "uv.lock"), []byte(uvLock), 0644)).To(Succeed()) - mockInstaller.EXPECT().InstallOnlyVersionWithStrip("uv", "/tmp/uv", 1) - mockCommand.EXPECT().RunWithOutput(gomock.Any()).Return([]byte("internal-lib==1.0.0\n"), nil) + DeferCleanup(os.Unsetenv, "UV_SYSTEM_CERTS") + Expect(os.Setenv("UV_SYSTEM_CERTS", "false")).To(Succeed()) }) - It("uses it as the primary index", func() { - Expect(supplier.InstallUv()).To(Succeed()) - - requirementsContents, err := os.ReadFile(filepath.Join(buildDir, "requirements.txt")) - Expect(err).ToNot(HaveOccurred()) - Expect(string(requirementsContents)).To(Equal("-i https://artifactory.example.com/simple\ninternal-lib==1.0.0\n")) - }) - }) - - Context("when uv.lock only uses PyPI", func() { - BeforeEach(func() { - const uvLock = `version = 1 - -[[package]] -name = "flask" -version = "3.1.3" -source = { registry = "https://pypi.org/simple" } -` - Expect(os.WriteFile(filepath.Join(buildDir, "uv.lock"), []byte(uvLock), 0644)).To(Succeed()) + It("does not override it", func() { mockInstaller.EXPECT().InstallOnlyVersionWithStrip("uv", "/tmp/uv", 1) - mockCommand.EXPECT().RunWithOutput(gomock.Any()).Return([]byte("flask==3.1.3\n"), nil) - }) + mockCommand.EXPECT().RunWithOutput(gomock.Any()).DoAndReturn(func(cmd *exec.Cmd) ([]byte, error) { + Expect(cmd.Env).To(ContainElement("UV_SYSTEM_CERTS=false")) + Expect(cmd.Env).NotTo(ContainElement("UV_SYSTEM_CERTS=1")) + return []byte("flask==3.1.3\n"), nil + }) - It("does not add index options", func() { Expect(supplier.InstallUv()).To(Succeed()) - - requirementsContents, err := os.ReadFile(filepath.Join(buildDir, "requirements.txt")) - Expect(err).ToNot(HaveOccurred()) - Expect(string(requirementsContents)).To(Equal("flask==3.1.3\n")) }) }) @@ -540,6 +460,34 @@ source = { registry = "https://pypi.org/simple" } }) }) + Describe("RunUvSync", func() { + It("installs the locked packages into the Python installation", func() { + mockCommand.EXPECT().Run(gomock.Any()).DoAndReturn(func(cmd *exec.Cmd) error { + Expect(cmd.Path).To(Equal("/tmp/uv/uv")) + Expect(cmd.Args[1:]).To(Equal([]string{"sync", "--frozen", "--no-dev", "--no-editable", "--inexact"})) + Expect(cmd.Dir).To(Equal(buildDir)) + Expect(cmd.Env).To(ContainElements( + "UV_PROJECT_ENVIRONMENT="+filepath.Join(depDir, "python"), + "UV_PYTHON="+filepath.Join(depDir, "python", "bin", "python"), + "UV_PYTHON_DOWNLOADS=never", + )) + return nil + }) + mockStager.EXPECT().LinkDirectoryInDepDir(filepath.Join(depDir, "python", "bin"), "bin") + + Expect(supplier.RunUvSync()).To(Succeed()) + Expect(buffer.String()).To(ContainSubstring("Running uv sync")) + }) + + Context("when uv sync fails", func() { + It("returns a useful error", func() { + mockCommand.EXPECT().Run(gomock.Any()).Return(errors.New("exit status 1")) + + Expect(supplier.RunUvSync()).To(MatchError("could not run uv sync: exit status 1")) + }) + }) + }) + Describe("MoveGeneratedRequirementsTxt", func() { BeforeEach(func() { Expect(os.MkdirAll(depDir, 0755)).To(Succeed()) From 93dd19c0a7b8c5377e263677296bf5db5ff96385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=BCller=2C=20Dominik?= Date: Mon, 28 Sep 2026 15:22:10 +0200 Subject: [PATCH 10/14] Install vendored uv apps offline with hash verification 'uv sync' only downloads the files referenced in uv.lock and cannot install from a vendor directory. For vendored uv apps, install the packages exported from uv.lock with 'uv pip install --offline --no-index --find-links vendor' instead. The exported requirements contain the hashes from uv.lock, so modified or mismatching files in the vendor directory are rejected. --- src/python/supply/supply.go | 38 +++++++++++++++++++++++++++++++- src/python/supply/supply_test.go | 32 +++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/python/supply/supply.go b/src/python/supply/supply.go index 79f101905..ba6c93ba8 100644 --- a/src/python/supply/supply.go +++ b/src/python/supply/supply.go @@ -152,7 +152,12 @@ func RunPython(s *Supplier) error { return fmt.Errorf("could not check vendor existence: %v", err) } - if s.usesUv && !vendored { + if s.usesUv && vendored { + if err := s.RunUvVendored(); err != nil { + s.Log.Error("Could not install vendored packages with uv: %v", err) + return err + } + } else if s.usesUv { if err := s.RunUvSync(); err != nil { s.Log.Error("Could not install packages with uv: %v", err) return err @@ -590,6 +595,37 @@ func (s *Supplier) RunUvSync() error { return s.Stager.LinkDirectoryInDepDir(filepath.Join(s.Stager.DepDir(), "python", "bin"), "bin") } +// RunUvVendored installs the packages exported from uv.lock from the vendor +// directory without network access. 'uv sync' cannot be used for this, as it +// only downloads the files referenced in uv.lock. The hashes in the exported +// requirements.txt are still verified. +func (s *Supplier) RunUvVendored() error { + s.Log.BeginStep("Running uv pip install (Vendored)") + + pythonPath := filepath.Join(s.Stager.DepDir(), "python", "bin", "python") + args := []string{ + "pip", "install", + "--python", pythonPath, + "--offline", + "--no-index", + "--find-links", filepath.Join(s.Stager.BuildDir(), "vendor"), + "-r", filepath.Join(s.Stager.BuildDir(), "requirements.txt"), + } + s.Log.Info("uv %s", strings.Join(args, " ")) + + cmd := exec.Command(filepath.Join(uvDir, "uv"), args...) + cmd.Dir = s.Stager.BuildDir() + cmd.Env = s.uvEnv() + cmd.Stdout = indentWriter(os.Stdout) + cmd.Stderr = indentWriter(os.Stderr) + if err := s.Command.Run(cmd); err != nil { + s.Log.Info("Running uv pip install failed. The vendor directory must contain all packages from uv.lock, matching the hashes in uv.lock.") + return fmt.Errorf("could not run uv pip install: %v", err) + } + + return s.Stager.LinkDirectoryInDepDir(filepath.Join(s.Stager.DepDir(), "python", "bin"), "bin") +} + // uvExportArgs exports the locked production dependencies without touching // the network. The project itself is only emitted if it is a package (i.e. // it defines a build-system). diff --git a/src/python/supply/supply_test.go b/src/python/supply/supply_test.go index 2ec7ae247..56965527c 100644 --- a/src/python/supply/supply_test.go +++ b/src/python/supply/supply_test.go @@ -488,6 +488,38 @@ var _ = Describe("Supply", func() { }) }) + Describe("RunUvVendored", func() { + It("installs the exported packages from the vendor directory", func() { + mockCommand.EXPECT().Run(gomock.Any()).DoAndReturn(func(cmd *exec.Cmd) error { + Expect(cmd.Path).To(Equal("/tmp/uv/uv")) + Expect(cmd.Args[1:]).To(Equal([]string{ + "pip", "install", + "--python", filepath.Join(depDir, "python", "bin", "python"), + "--offline", + "--no-index", + "--find-links", filepath.Join(buildDir, "vendor"), + "-r", filepath.Join(buildDir, "requirements.txt"), + })) + Expect(cmd.Dir).To(Equal(buildDir)) + Expect(cmd.Env).To(ContainElement("UV_PYTHON_DOWNLOADS=never")) + return nil + }) + mockStager.EXPECT().LinkDirectoryInDepDir(filepath.Join(depDir, "python", "bin"), "bin") + + Expect(supplier.RunUvVendored()).To(Succeed()) + Expect(buffer.String()).To(ContainSubstring("Running uv pip install (Vendored)")) + }) + + Context("when uv pip install fails", func() { + It("alerts the user", func() { + mockCommand.EXPECT().Run(gomock.Any()).Return(errors.New("exit status 1")) + + Expect(supplier.RunUvVendored()).To(MatchError("could not run uv pip install: exit status 1")) + Expect(buffer.String()).To(ContainSubstring("The vendor directory must contain all packages from uv.lock, matching the hashes in uv.lock.")) + }) + }) + }) + Describe("MoveGeneratedRequirementsTxt", func() { BeforeEach(func() { Expect(os.MkdirAll(depDir, 0755)).To(Succeed()) From 7397cdfa0c8b758a7720fcb85e16f31a70ef343c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=BCller=2C=20Dominik?= Date: Mon, 28 Sep 2026 15:25:53 +0200 Subject: [PATCH 11/14] Extend integration tests for uv sync Verify that uv apps are installed with uv sync, that staging fails when a package does not match the hash in uv.lock, and that collectstatic runs for a Django app managed with uv. --- fixtures/uv_django/.gitignore | 3 + fixtures/uv_django/Procfile | 1 + fixtures/uv_django/django_web_app/__init__.py | 0 fixtures/uv_django/django_web_app/asgi.py | 16 +++ fixtures/uv_django/django_web_app/settings.py | 119 ++++++++++++++++ fixtures/uv_django/django_web_app/urls.py | 21 +++ fixtures/uv_django/django_web_app/wsgi.py | 16 +++ fixtures/uv_django/manage.py | 22 +++ fixtures/uv_django/pyproject.toml | 11 ++ fixtures/uv_django/uv.lock | 128 ++++++++++++++++++ src/python/integration/uv_test.go | 59 +++++++- 11 files changed, 392 insertions(+), 4 deletions(-) create mode 100644 fixtures/uv_django/.gitignore create mode 100644 fixtures/uv_django/Procfile create mode 100644 fixtures/uv_django/django_web_app/__init__.py create mode 100644 fixtures/uv_django/django_web_app/asgi.py create mode 100644 fixtures/uv_django/django_web_app/settings.py create mode 100644 fixtures/uv_django/django_web_app/urls.py create mode 100644 fixtures/uv_django/django_web_app/wsgi.py create mode 100755 fixtures/uv_django/manage.py create mode 100644 fixtures/uv_django/pyproject.toml create mode 100644 fixtures/uv_django/uv.lock diff --git a/fixtures/uv_django/.gitignore b/fixtures/uv_django/.gitignore new file mode 100644 index 000000000..88c4100e4 --- /dev/null +++ b/fixtures/uv_django/.gitignore @@ -0,0 +1,3 @@ +*.pyc +.venv +static_root diff --git a/fixtures/uv_django/Procfile b/fixtures/uv_django/Procfile new file mode 100644 index 000000000..61ab607f6 --- /dev/null +++ b/fixtures/uv_django/Procfile @@ -0,0 +1 @@ +web: waitress-serve --port=$PORT django_web_app.wsgi:application diff --git a/fixtures/uv_django/django_web_app/__init__.py b/fixtures/uv_django/django_web_app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fixtures/uv_django/django_web_app/asgi.py b/fixtures/uv_django/django_web_app/asgi.py new file mode 100644 index 000000000..06cbcf4b6 --- /dev/null +++ b/fixtures/uv_django/django_web_app/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for django_web_app project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_web_app.settings') + +application = get_asgi_application() diff --git a/fixtures/uv_django/django_web_app/settings.py b/fixtures/uv_django/django_web_app/settings.py new file mode 100644 index 000000000..b8832722e --- /dev/null +++ b/fixtures/uv_django/django_web_app/settings.py @@ -0,0 +1,119 @@ +""" +Django settings for django_web_app project. + +Generated by 'django-admin startproject' using Django 4.1.5. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.1/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-1&02gvsqhx5gvh44@)x%k03&79r*vkievzv0%yl*+vq5m)unm_' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ['*'] + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'django_web_app.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'django_web_app.wsgi.application' + +# Database +# https://docs.djangoproject.com/en/4.1/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + +# Password validation +# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +# Internationalization +# https://docs.djangoproject.com/en/4.1/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.1/howto/static-files/ + +STATIC_URL = '/static/' +STATIC_ROOT = 'static_root' + +# Default primary key field type +# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/fixtures/uv_django/django_web_app/urls.py b/fixtures/uv_django/django_web_app/urls.py new file mode 100644 index 000000000..8d90cd0db --- /dev/null +++ b/fixtures/uv_django/django_web_app/urls.py @@ -0,0 +1,21 @@ +"""django_web_app URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path('admin/', admin.site.urls), +] diff --git a/fixtures/uv_django/django_web_app/wsgi.py b/fixtures/uv_django/django_web_app/wsgi.py new file mode 100644 index 000000000..270728c0e --- /dev/null +++ b/fixtures/uv_django/django_web_app/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for django_web_app project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_web_app.settings') + +application = get_wsgi_application() diff --git a/fixtures/uv_django/manage.py b/fixtures/uv_django/manage.py new file mode 100755 index 000000000..f22506b2d --- /dev/null +++ b/fixtures/uv_django/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_web_app.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/fixtures/uv_django/pyproject.toml b/fixtures/uv_django/pyproject.toml new file mode 100644 index 000000000..95280905b --- /dev/null +++ b/fixtures/uv_django/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "uv-django-app" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [ + "dj-database-url==1.2.0", + "Django==4.1.5", + "gunicorn==20.1.0", + "waitress==2.1.2", + "whitenoise==6.3.0", +] diff --git a/fixtures/uv_django/uv.lock b/fixtures/uv_django/uv.lock new file mode 100644 index 000000000..9fe6ccfc1 --- /dev/null +++ b/fixtures/uv_django/uv.lock @@ -0,0 +1,128 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "asgiref" +version = "3.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, +] + +[[package]] +name = "dj-database-url" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/3f/c55c0152ca441111af0f969f2d37b99279874ab4f313a3262fed7bf6aaef/dj-database-url-1.2.0.tar.gz", hash = "sha256:b23b15046cb38180e0c95207bcc90fe5e9dbde8eef16065907dd85cf4ca7036c", size = 7310, upload-time = "2022-12-13T09:24:11.475Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/62/e2786a98fda838f25ce86782810930b54eec2ad87d37950e80c3ce3538b8/dj_database_url-1.2.0-py3-none-any.whl", hash = "sha256:5c2993b91801c0f78a8b19e642b497b90831124cbade0c265900d4c1037b4730", size = 7089, upload-time = "2022-12-13T09:24:29.732Z" }, +] + +[[package]] +name = "django" +version = "4.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/c8/b3c469353f9d1b7f0e99b45520582b891da02cd87408bc867affa6e039a3/Django-4.1.5.tar.gz", hash = "sha256:ff56ebd7ead0fd5dbe06fe157b0024a7aaea2e0593bb3785fb594cf94dad58ef", size = 10507020, upload-time = "2023-01-02T07:34:49.562Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/ac/9f013a51e6008ba94a282c15778a3ea51a0953f6711a77f9baa471fd1b1d/Django-4.1.5-py3-none-any.whl", hash = "sha256:4b214a05fe4c99476e99e2445c8b978c8369c18d4dea8e22ec412862715ad763", size = 8102437, upload-time = "2023-01-02T07:34:45.541Z" }, +] + +[[package]] +name = "gunicorn" +version = "20.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/5b/0d1f0296485a6af03366604142ea8f19f0833894db3512a40ed07b2a56dd/gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8", size = 370601, upload-time = "2021-03-27T01:54:37.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/dd/5b190393e6066286773a67dfcc2f9492058e9b57c4867a95f1ba5caf0a83/gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e", size = 79531, upload-time = "2021-04-27T12:16:23.375Z" }, +] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + +[[package]] +name = "sqlparse" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" }, +] + +[[package]] +name = "uv-django-app" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "dj-database-url" }, + { name = "django" }, + { name = "gunicorn" }, + { name = "waitress" }, + { name = "whitenoise" }, +] + +[package.metadata] +requires-dist = [ + { name = "dj-database-url", specifier = "==1.2.0" }, + { name = "django", specifier = "==4.1.5" }, + { name = "gunicorn", specifier = "==20.1.0" }, + { name = "waitress", specifier = "==2.1.2" }, + { name = "whitenoise", specifier = "==6.3.0" }, +] + +[[package]] +name = "waitress" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/83/c3de9799e2305898b02ea67bcd125ad06f271e2a82cc86fe66b7bf4e6f63/waitress-2.1.2.tar.gz", hash = "sha256:780a4082c5fbc0fde6a2fcfe5e26e6efc1e8f425730863c04085769781f51eba", size = 179823, upload-time = "2022-05-30T21:46:21.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/6a/b4b5c582e04e837e4422cab6ec9de7fc10ca7ad7f4e370bb89d280d39552/waitress-2.1.2-py3-none-any.whl", hash = "sha256:7500c9625927c8ec60f54377d590f67b30c8e70ef4b8894214ac6e4cad233d2a", size = 57741, upload-time = "2022-05-30T21:46:19.371Z" }, +] + +[[package]] +name = "whitenoise" +version = "6.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/84/c05ab17d96e2546c120a9271d7f696b6e355397176482b07ae62153bd555/whitenoise-6.3.0.tar.gz", hash = "sha256:fe0af31504ab08faa1ec7fc02845432096e40cc1b27e6a7747263d7b30fb51fa", size = 18566, upload-time = "2023-01-03T10:31:35.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/fd/90d04468b1b05445a2f0686bd6d30bf9b5512d85026d88f3679b7f417b49/whitenoise-6.3.0-py3-none-any.whl", hash = "sha256:cf8ecf56d86ba1c734fdb5ef6127312e39e92ad5947fef9033dc9e43ba2777d9", size = 19997, upload-time = "2023-01-03T10:31:33.56Z" }, +] diff --git a/src/python/integration/uv_test.go b/src/python/integration/uv_test.go index 4ab846c7e..00bf35b31 100644 --- a/src/python/integration/uv_test.go +++ b/src/python/integration/uv_test.go @@ -3,6 +3,7 @@ package integration_test import ( "os" "path/filepath" + "strings" "testing" "github.com/cloudfoundry/switchblade" @@ -49,11 +50,61 @@ func testUv(platform switchblade.Platform, fixtures string) func(*testing.T, spe ContainSubstring("Using Python version 3.12 from .python-version"), MatchRegexp(`Installing python 3\.12\.\d+`), ContainSubstring("Installing uv"), - ContainSubstring("Generating 'requirements.txt' from uv.lock"), - ContainSubstring("Running Pip Install (Unvendored)"), - ContainSubstring("Collecting flask==3.1.3"), - ContainSubstring("Collecting gunicorn==26.2.0"), + ContainSubstring("Running uv sync"), + ContainSubstring("+ flask==3.1.3"), + ContainSubstring("+ gunicorn==26.2.0"), Not(ContainSubstring("pytest")), + Not(ContainSubstring("Running Pip Install")), + )) + }) + }) + + context("when pushing an app whose uv.lock contains a wrong hash", func() { + it.Before(func() { + lockPath := filepath.Join(source, "uv.lock") + lock, err := os.ReadFile(lockPath) + Expect(err).NotTo(HaveOccurred()) + + const blinkerWheelHash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc" + Expect(string(lock)).To(ContainSubstring(blinkerWheelHash)) + lock = []byte(strings.ReplaceAll(string(lock), blinkerWheelHash, "sha256:"+strings.Repeat("0", 64))) + Expect(os.WriteFile(lockPath, lock, 0644)).To(Succeed()) + }) + + it("fails to stage", func() { + _, logs, err := platform.Deploy. + WithBuildpacks("python_buildpack"). + Execute(name, source) + Expect(err).To(HaveOccurred()) + + Expect(logs.String()).To(SatisfyAll( + ContainSubstring("Hash mismatch for `blinker==1.9.0`"), + ContainSubstring("Could not install packages with uv"), + )) + }) + }) + + context("when pushing a Django app that has a uv.lock", func() { + it.Before(func() { + Expect(os.RemoveAll(source)).To(Succeed()) + + var err error + source, err = switchblade.Source(filepath.Join(fixtures, "uv_django")) + Expect(err).NotTo(HaveOccurred()) + }) + + it("installs the packages with uv and runs collectstatic", func() { + deployment, logs, err := platform.Deploy. + WithBuildpacks("python_buildpack"). + Execute(name, source) + Expect(err).NotTo(HaveOccurred()) + + Eventually(deployment).Should(Serve(ContainSubstring("The install worked successfully!"))) + + Expect(logs.String()).To(SatisfyAll( + ContainSubstring("Running uv sync"), + ContainSubstring("+ django==4.1.5"), + ContainSubstring("collectstatic --noinput"), )) }) }) From 926c4aa3c0a172d59340141ce24daf2e7cf438f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=BCller=2C=20Dominik?= Date: Mon, 28 Sep 2026 15:26:18 +0200 Subject: [PATCH 12/14] Update README for uv sync --- README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b9771473e..c0e40e52e 100644 --- a/README.md +++ b/README.md @@ -14,18 +14,21 @@ Official buildpack documentation can be found at [python buildpack docs](http:// Apps managed with [uv](https://docs.astral.sh/uv/) are supported by pushing the `pyproject.toml` together with a `uv.lock`: -* During staging the buildpack runs `uv export --frozen` to generate a temporary `requirements.txt` from `uv.lock`, which is then installed with pip. Keep `uv.lock` up to date (`uv lock`) before pushing, as it is used as is. -* Only the default dependencies are installed, dependency groups such as `dev` are skipped. If the project defines a `[build-system]`, the project itself is installed as well. -* The Python version is taken from `.python-version` (e.g. `3.12`, as written by `uv python pin`), unless a `runtime.txt` exists. -* Package indexes configured via `[[tool.uv.index]]` are passed on to pip. Credentials are not part of `uv.lock`, so for authenticated indexes set `PIP_INDEX_URL` / `PIP_EXTRA_INDEX_URL` instead, which then take precedence. -* If the app also contains a `requirements.txt`, the `uv.lock` is ignored. -* For vendored (offline) installs, download the wheels for the target platform into the `vendor` directory. Write the exported file outside the app, so that it does not take precedence over `uv.lock`: +* During staging the buildpack runs `uv sync --frozen` to install the locked packages into the buildpack's Python installation. Every package is verified against the hashes in `uv.lock`, and staging fails on a mismatch. Keep `uv.lock` up to date (`uv lock`) before pushing, as it is used as is. +* Only the default dependencies are installed, dependency groups such as `dev` are skipped. If the project defines a `[build-system]`, the project itself is installed as well (non-editable). +* The Python version is taken from `.python-version` (e.g. `3.12`, as written by `uv python pin`), unless a `runtime.txt` exists. uv never downloads its own Python; staging fails if the version does not satisfy `requires-python`. +* Package indexes configured via `[[tool.uv.index]]` are used as configured. Credentials are not part of `uv.lock`; provide them as environment variables, e.g. `cf set-env UV_INDEX__USERNAME ...` and `UV_INDEX__PASSWORD`. uv trusts the system certificate store, like pip does. +* Django's `collectstatic`, as well as the installation of libffi and libmemcached, work as for pip apps. +* If the app also contains a `requirements.txt`, the `uv.lock` is ignored and pip is used. +* For vendored (offline) installs, download the wheels for the target platform into the `vendor` directory. They are installed with `uv pip install --offline` and are verified against the hashes in `uv.lock` as well. Write the exported file outside the app, so that it does not take precedence over `uv.lock`: ```bash - uv export --frozen --no-dev --no-hashes -o /tmp/requirements.txt + uv export --frozen --no-dev --no-hashes --no-emit-project -o /tmp/requirements.txt pip download -r /tmp/requirements.txt -d vendor --only-binary=:all: --platform manylinux2014_x86_64 --python-version 3.12 ``` + If the project defines a `[build-system]`, its build backend (e.g. `hatchling`) must be vendored as well. + ## Adding new dependencies If you want to add a new dependency to the buildpack, please add it to the [config.yml](https://github.com/cloudfoundry/buildpacks-ci/blob/5a63d13df09f83d5dff7c71d0a12c3e2dc798d39/pipelines/dependency-builds/config.yml#L272) file. For example, if you want to add a new version of Python, add an entry like the following: From 33e68ff9563aa1e53791eb4cc48c75f66e67da24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=BCller=2C=20Dominik?= Date: Mon, 26 Oct 2026 10:47:02 +0100 Subject: [PATCH 13/14] Install uv from the wheel shipped in the manifest The buildpack manifest now provides uv as a pip wheel built by the buildpacks pipeline (for cflinuxfs4 and cflinuxfs5) instead of the upstream release binary. Install that wheel with pip into /tmp/uv, so uv stays out of the droplet, and run uv from /tmp/uv/bin/uv. --- src/python/supply/mocks_test.go | 14 ------------ src/python/supply/supply.go | 37 +++++++++++++++++++++++++++----- src/python/supply/supply_test.go | 27 +++++++++++++++++------ 3 files changed, 52 insertions(+), 26 deletions(-) diff --git a/src/python/supply/mocks_test.go b/src/python/supply/mocks_test.go index 1cd2d410e..f0d378c1d 100644 --- a/src/python/supply/mocks_test.go +++ b/src/python/supply/mocks_test.go @@ -251,20 +251,6 @@ func (mr *MockInstallerMockRecorder) InstallOnlyVersion(depName, installDir inte return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstallOnlyVersion", reflect.TypeOf((*MockInstaller)(nil).InstallOnlyVersion), depName, installDir) } -// InstallOnlyVersionWithStrip mocks base method. -func (m *MockInstaller) InstallOnlyVersionWithStrip(depName, installDir string, stripComponents int) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InstallOnlyVersionWithStrip", depName, installDir, stripComponents) - ret0, _ := ret[0].(error) - return ret0 -} - -// InstallOnlyVersionWithStrip indicates an expected call of InstallOnlyVersionWithStrip. -func (mr *MockInstallerMockRecorder) InstallOnlyVersionWithStrip(depName, installDir, stripComponents interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstallOnlyVersionWithStrip", reflect.TypeOf((*MockInstaller)(nil).InstallOnlyVersionWithStrip), depName, installDir, stripComponents) -} - // MockCommand is a mock of Command interface. type MockCommand struct { ctrl *gomock.Controller diff --git a/src/python/supply/supply.go b/src/python/supply/supply.go index ba6c93ba8..bae23c323 100644 --- a/src/python/supply/supply.go +++ b/src/python/supply/supply.go @@ -23,6 +23,7 @@ import ( const EnvPipVersion = "BP_PIP_VERSION" var uvDir = filepath.Join("/tmp", "uv") +var uvBin = filepath.Join(uvDir, "bin", "uv") type Stager interface { BuildDir() string @@ -43,7 +44,6 @@ type Manifest interface { type Installer interface { InstallDependency(dep libbuildpack.Dependency, outputDir string) error InstallOnlyVersion(depName, installDir string) error - InstallOnlyVersionWithStrip(depName, installDir string, stripComponents int) error } type Command interface { @@ -557,13 +557,13 @@ func (s *Supplier) InstallUv() error { } s.Log.Info("Installing uv") - if err := s.Installer.InstallOnlyVersionWithStrip("uv", uvDir, 1); err != nil { + if err := s.installUv(); err != nil { return err } s.usesUv = true s.Log.Info("Generating 'requirements.txt' from uv.lock") - cmd := exec.Command(filepath.Join(uvDir, "uv"), uvExportArgs()...) + cmd := exec.Command(uvBin, uvExportArgs()...) cmd.Dir = s.Stager.BuildDir() cmd.Env = s.uvEnv() cmd.Stderr = indentWriter(os.Stderr) @@ -575,6 +575,33 @@ func (s *Supplier) InstallUv() error { return s.writeTempRequirementsTxt(string(output)) } +// installUv installs the uv wheel from the manifest into uvDir, outside of the +// Python installation, so that uv does not end up in the droplet. +func (s *Supplier) installUv() error { + wheelDir, err := os.MkdirTemp("", "uv-wheel") + if err != nil { + return err + } + defer os.RemoveAll(wheelDir) + + if err := s.Installer.InstallOnlyVersion("uv", wheelDir); err != nil { + return err + } + + if err := s.Command.Execute(s.Stager.BuildDir(), indentWriter(os.Stdout), indentWriter(os.Stderr), + "python", + "-m", "pip", + "install", "uv", + "--no-index", + fmt.Sprintf("--find-links=%s", wheelDir), + fmt.Sprintf("--target=%s", uvDir), + ); err != nil { + return fmt.Errorf("could not install uv: %v", err) + } + + return nil +} + // RunUvSync installs the locked packages with 'uv sync' directly into the // Python installation, verifying them against the hashes in uv.lock. func (s *Supplier) RunUvSync() error { @@ -583,7 +610,7 @@ func (s *Supplier) RunUvSync() error { args := uvSyncArgs() s.Log.Info("uv %s", strings.Join(args, " ")) - cmd := exec.Command(filepath.Join(uvDir, "uv"), args...) + cmd := exec.Command(uvBin, args...) cmd.Dir = s.Stager.BuildDir() cmd.Env = append(s.uvEnv(), "UV_PROJECT_ENVIRONMENT="+filepath.Join(s.Stager.DepDir(), "python")) cmd.Stdout = indentWriter(os.Stdout) @@ -613,7 +640,7 @@ func (s *Supplier) RunUvVendored() error { } s.Log.Info("uv %s", strings.Join(args, " ")) - cmd := exec.Command(filepath.Join(uvDir, "uv"), args...) + cmd := exec.Command(uvBin, args...) cmd.Dir = s.Stager.BuildDir() cmd.Env = s.uvEnv() cmd.Stdout = indentWriter(os.Stdout) diff --git a/src/python/supply/supply_test.go b/src/python/supply/supply_test.go index 56965527c..a0a116d72 100644 --- a/src/python/supply/supply_test.go +++ b/src/python/supply/supply_test.go @@ -396,9 +396,10 @@ var _ = Describe("Supply", func() { It("installs uv and generates requirements.txt with hashes using uv export", func() { const exported = "flask==3.1.3 \\\n --hash=sha256:4eda\ngunicorn==26.2.0 \\\n --hash=sha256:17ef\n" - mockInstaller.EXPECT().InstallOnlyVersionWithStrip("uv", "/tmp/uv", 1) + mockInstaller.EXPECT().InstallOnlyVersion("uv", gomock.Any()) + mockCommand.EXPECT().Execute(buildDir, gomock.Any(), gomock.Any(), "python", "-m", "pip", "install", "uv", "--no-index", gomock.Any(), "--target=/tmp/uv") mockCommand.EXPECT().RunWithOutput(gomock.Any()).DoAndReturn(func(cmd *exec.Cmd) ([]byte, error) { - Expect(cmd.Path).To(Equal("/tmp/uv/uv")) + Expect(cmd.Path).To(Equal("/tmp/uv/bin/uv")) Expect(cmd.Args[1:]).To(Equal([]string{ "export", "--frozen", "--format", "requirements-txt", "--no-dev", "--no-editable", "--no-header", "--no-annotate", @@ -428,7 +429,8 @@ var _ = Describe("Supply", func() { }) It("does not override it", func() { - mockInstaller.EXPECT().InstallOnlyVersionWithStrip("uv", "/tmp/uv", 1) + mockInstaller.EXPECT().InstallOnlyVersion("uv", gomock.Any()) + mockCommand.EXPECT().Execute(buildDir, gomock.Any(), gomock.Any(), "python", "-m", "pip", "install", "uv", "--no-index", gomock.Any(), "--target=/tmp/uv") mockCommand.EXPECT().RunWithOutput(gomock.Any()).DoAndReturn(func(cmd *exec.Cmd) ([]byte, error) { Expect(cmd.Env).To(ContainElement("UV_SYSTEM_CERTS=false")) Expect(cmd.Env).NotTo(ContainElement("UV_SYSTEM_CERTS=1")) @@ -441,16 +443,27 @@ var _ = Describe("Supply", func() { Context("when uv cannot be installed", func() { It("returns the error", func() { - mockInstaller.EXPECT().InstallOnlyVersionWithStrip("uv", "/tmp/uv", 1).Return(errors.New("no versions of uv found")) + mockInstaller.EXPECT().InstallOnlyVersion("uv", gomock.Any()).Return(errors.New("no versions of uv found")) Expect(supplier.InstallUv()).To(MatchError("no versions of uv found")) Expect(filepath.Join(buildDir, "requirements.txt")).NotTo(BeAnExistingFile()) }) }) + Context("when pip cannot install the uv wheel", func() { + It("returns a useful error", func() { + mockInstaller.EXPECT().InstallOnlyVersion("uv", gomock.Any()) + mockCommand.EXPECT().Execute(buildDir, gomock.Any(), gomock.Any(), "python", "-m", "pip", "install", "uv", "--no-index", gomock.Any(), "--target=/tmp/uv").Return(errors.New("exit status 1")) + + Expect(supplier.InstallUv()).To(MatchError("could not install uv: exit status 1")) + Expect(filepath.Join(buildDir, "requirements.txt")).NotTo(BeAnExistingFile()) + }) + }) + Context("when uv export fails", func() { It("returns a useful error", func() { - mockInstaller.EXPECT().InstallOnlyVersionWithStrip("uv", "/tmp/uv", 1) + mockInstaller.EXPECT().InstallOnlyVersion("uv", gomock.Any()) + mockCommand.EXPECT().Execute(buildDir, gomock.Any(), gomock.Any(), "python", "-m", "pip", "install", "uv", "--no-index", gomock.Any(), "--target=/tmp/uv") mockCommand.EXPECT().RunWithOutput(gomock.Any()).Return(nil, errors.New("exit status 2")) Expect(supplier.InstallUv()).To(MatchError("failed to export uv.lock: exit status 2")) @@ -463,7 +476,7 @@ var _ = Describe("Supply", func() { Describe("RunUvSync", func() { It("installs the locked packages into the Python installation", func() { mockCommand.EXPECT().Run(gomock.Any()).DoAndReturn(func(cmd *exec.Cmd) error { - Expect(cmd.Path).To(Equal("/tmp/uv/uv")) + Expect(cmd.Path).To(Equal("/tmp/uv/bin/uv")) Expect(cmd.Args[1:]).To(Equal([]string{"sync", "--frozen", "--no-dev", "--no-editable", "--inexact"})) Expect(cmd.Dir).To(Equal(buildDir)) Expect(cmd.Env).To(ContainElements( @@ -491,7 +504,7 @@ var _ = Describe("Supply", func() { Describe("RunUvVendored", func() { It("installs the exported packages from the vendor directory", func() { mockCommand.EXPECT().Run(gomock.Any()).DoAndReturn(func(cmd *exec.Cmd) error { - Expect(cmd.Path).To(Equal("/tmp/uv/uv")) + Expect(cmd.Path).To(Equal("/tmp/uv/bin/uv")) Expect(cmd.Args[1:]).To(Equal([]string{ "pip", "install", "--python", filepath.Join(depDir, "python", "bin", "python"), From cab57998b15ee8e53ab4ee84f8dca28aebee1f84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=BCller=2C=20Dominik?= Date: Wed, 28 Oct 2026 09:49:14 +0100 Subject: [PATCH 14/14] Address review feedback on uv support - Ignore .python-version when the app provides a requirements.txt, as uv.lock is ignored in that case as well. - Only keep a copy of the generated requirements.txt in the deps dir for uv apps. Pipfile apps keep their previous behaviour, so collectstatic does not suddenly run for Pipfile Django apps. - Only detect uv apps that provide both uv.lock and pyproject.toml. - Start from an empty /tmp/uv when installing uv. - Mention the build backend when a vendored uv install fails. - Compile the package name regex once. --- README.md | 2 +- bin/detect | 2 +- src/python/finalize/finalize.go | 4 +-- src/python/requirements/requirements.go | 14 +++++++-- src/python/supply/supply.go | 27 +++++++++++----- src/python/supply/supply_test.go | 42 +++++++++++++++++++++---- 6 files changed, 71 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index c0e40e52e..5eda0cd12 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Apps managed with [uv](https://docs.astral.sh/uv/) are supported by pushing the * The Python version is taken from `.python-version` (e.g. `3.12`, as written by `uv python pin`), unless a `runtime.txt` exists. uv never downloads its own Python; staging fails if the version does not satisfy `requires-python`. * Package indexes configured via `[[tool.uv.index]]` are used as configured. Credentials are not part of `uv.lock`; provide them as environment variables, e.g. `cf set-env UV_INDEX__USERNAME ...` and `UV_INDEX__PASSWORD`. uv trusts the system certificate store, like pip does. * Django's `collectstatic`, as well as the installation of libffi and libmemcached, work as for pip apps. -* If the app also contains a `requirements.txt`, the `uv.lock` is ignored and pip is used. +* If the app also contains a `requirements.txt`, the `uv.lock` and `.python-version` are ignored and pip is used. * For vendored (offline) installs, download the wheels for the target platform into the `vendor` directory. They are installed with `uv pip install --offline` and are verified against the hashes in `uv.lock` as well. Write the exported file outside the app, so that it does not take precedence over `uv.lock`: ```bash diff --git a/bin/detect b/bin/detect index 5a4557446..e4d29ad92 100755 --- a/bin/detect +++ b/bin/detect @@ -13,7 +13,7 @@ BUILD_DIR=$1 BP=$(dirname $(dirname $0)) # Exit early if app is clearly not Python. -if [ ! -f $BUILD_DIR/requirements.txt ] && [ ! -f $BUILD_DIR/setup.py ] && [ ! -f $BUILD_DIR/environment.yml ] && [ ! -f $BUILD_DIR/Pipfile ] && [ ! -f $BUILD_DIR/uv.lock ]; then +if [ ! -f $BUILD_DIR/requirements.txt ] && [ ! -f $BUILD_DIR/setup.py ] && [ ! -f $BUILD_DIR/environment.yml ] && [ ! -f $BUILD_DIR/Pipfile ] && { [ ! -f $BUILD_DIR/uv.lock ] || [ ! -f $BUILD_DIR/pyproject.toml ]; }; then exit 1 fi diff --git a/src/python/finalize/finalize.go b/src/python/finalize/finalize.go index 9bada3ce2..ffd9e1cfa 100644 --- a/src/python/finalize/finalize.go +++ b/src/python/finalize/finalize.go @@ -75,8 +75,8 @@ func (f *Finalizer) HandleCollectstatic() error { return nil } - // Supply moves a generated requirements.txt (e.g. from Pipfile.lock or - // uv.lock) from the app directory to the deps dir. + // Supply moves the requirements.txt generated from uv.lock from the app + // directory to the deps dir. var exists bool for _, dir := range []string{f.Stager.BuildDir(), f.Stager.DepDir()} { found, err := f.Requirements.FindAnyPackage(dir, "django") diff --git a/src/python/requirements/requirements.go b/src/python/requirements/requirements.go index 4e829b34a..fea7d9c39 100644 --- a/src/python/requirements/requirements.go +++ b/src/python/requirements/requirements.go @@ -11,6 +11,8 @@ import ( const requirementsParserRegex = `(?m)^[\w\-\w\[\]]+` +var packageNameSeparatorRegex = regexp.MustCompile(`[-_.]+`) + type Reqs struct{} func (m Reqs) FindAnyPackage(buildDir string, searchedPackages ...string) (bool, error) { @@ -28,9 +30,15 @@ func (m Reqs) FindAnyPackage(buildDir string, searchedPackages ...string) (bool, return false, err } + normalizedSearchedPackages := make([]string, len(searchedPackages)) + for i, searchedPackage := range searchedPackages { + normalizedSearchedPackages[i] = normalizePackageName(searchedPackage) + } + for _, reqPackage := range reqPackages { - for _, searchedPackage := range searchedPackages { - if normalizePackageName(reqPackage) == normalizePackageName(searchedPackage) { + normalizedReqPackage := normalizePackageName(reqPackage) + for _, searchedPackage := range normalizedSearchedPackages { + if normalizedReqPackage == searchedPackage { return true, nil } } @@ -86,7 +94,7 @@ func containsPackage(packages []string, searchedPackage string) bool { // normalizePackageName normalizes a package name as defined in PEP 503, since // e.g. 'PyNaCl' may be written as 'pynacl' in generated requirements files. func normalizePackageName(name string) string { - return strings.ToLower(regexp.MustCompile(`[-_.]+`).ReplaceAllString(name, "-")) + return strings.ToLower(packageNameSeparatorRegex.ReplaceAllString(name, "-")) } func packageIsExcluded(excludedPackages []string, packageFullName string) bool { diff --git a/src/python/supply/supply.go b/src/python/supply/supply.go index bae23c323..219d5d3a0 100644 --- a/src/python/supply/supply.go +++ b/src/python/supply/supply.go @@ -242,13 +242,19 @@ func (s *Supplier) HandlePipfile() error { } // HandleUvPythonVersion writes runtime.txt from the .python-version file of a -// uv app, unless the app provides its own runtime.txt. +// uv app, unless the app provides its own runtime.txt. Like InstallUv, it +// ignores apps that provide a requirements.txt. func (s *Supplier) HandleUvPythonVersion() error { uvLockExists, err := libbuildpack.FileExists(filepath.Join(s.Stager.BuildDir(), "uv.lock")) if err != nil { return err } + requirementstxtExists, err := libbuildpack.FileExists(filepath.Join(s.Stager.BuildDir(), "requirements.txt")) + if err != nil { + return err + } + runtimeExists, err := libbuildpack.FileExists(filepath.Join(s.Stager.DepDir(), "runtime.txt")) if err != nil { return err @@ -260,7 +266,7 @@ func (s *Supplier) HandleUvPythonVersion() error { return err } - if !uvLockExists || runtimeExists || !pythonVersionExists { + if !uvLockExists || requirementstxtExists || runtimeExists || !pythonVersionExists { return nil } @@ -588,6 +594,10 @@ func (s *Supplier) installUv() error { return err } + if err := os.RemoveAll(uvDir); err != nil { + return err + } + if err := s.Command.Execute(s.Stager.BuildDir(), indentWriter(os.Stdout), indentWriter(os.Stderr), "python", "-m", "pip", @@ -646,7 +656,7 @@ func (s *Supplier) RunUvVendored() error { cmd.Stdout = indentWriter(os.Stdout) cmd.Stderr = indentWriter(os.Stderr) if err := s.Command.Run(cmd); err != nil { - s.Log.Info("Running uv pip install failed. The vendor directory must contain all packages from uv.lock, matching the hashes in uv.lock.") + s.Log.Info("Running uv pip install failed. The vendor directory must contain all packages from uv.lock, matching the hashes in uv.lock. If the project defines a [build-system], its build backend must be vendored as well.") return fmt.Errorf("could not run uv pip install: %v", err) } @@ -1172,16 +1182,19 @@ func (s *Supplier) writeTempRequirementsTxt(content string) error { } // MoveGeneratedRequirementsTxt removes a generated requirements.txt from the -// app directory. A copy is kept in the deps dir, so that finalize can still -// detect packages like Django. +// app directory. For uv apps, a copy is kept in the deps dir, so that +// finalize can still detect packages like Django. Pipfile apps keep their +// previous behaviour and do not get a copy. func (s *Supplier) MoveGeneratedRequirementsTxt() error { if !s.removeRequirementsText { return nil } requirementsPath := filepath.Join(s.Stager.BuildDir(), "requirements.txt") - if err := libbuildpack.CopyFile(requirementsPath, filepath.Join(s.Stager.DepDir(), "requirements.txt")); err != nil { - return err + if s.usesUv { + if err := libbuildpack.CopyFile(requirementsPath, filepath.Join(s.Stager.DepDir(), "requirements.txt")); err != nil { + return err + } } return os.Remove(requirementsPath) } diff --git a/src/python/supply/supply_test.go b/src/python/supply/supply_test.go index a0a116d72..dfbd5dd10 100644 --- a/src/python/supply/supply_test.go +++ b/src/python/supply/supply_test.go @@ -261,6 +261,18 @@ var _ = Describe("Supply", func() { }) }) + Context("when the app provides a requirements.txt", func() { + BeforeEach(func() { + Expect(os.WriteFile(filepath.Join(buildDir, "requirements.txt"), []byte("flask"), 0644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(buildDir, ".python-version"), []byte("3.12\n"), 0644)).To(Succeed()) + }) + + It("ignores .python-version, as uv.lock is ignored as well", func() { + Expect(supplier.HandleUvPythonVersion()).To(Succeed()) + Expect(filepath.Join(depDir, "runtime.txt")).NotTo(BeAnExistingFile()) + }) + }) + Context("when the app does not use uv", func() { BeforeEach(func() { Expect(os.Remove(filepath.Join(buildDir, "uv.lock"))).To(Succeed()) @@ -528,7 +540,7 @@ var _ = Describe("Supply", func() { mockCommand.EXPECT().Run(gomock.Any()).Return(errors.New("exit status 1")) Expect(supplier.RunUvVendored()).To(MatchError("could not run uv pip install: exit status 1")) - Expect(buffer.String()).To(ContainSubstring("The vendor directory must contain all packages from uv.lock, matching the hashes in uv.lock.")) + Expect(buffer.String()).To(ContainSubstring("The vendor directory must contain all packages from uv.lock, matching the hashes in uv.lock. If the project defines a [build-system], its build backend must be vendored as well.")) }) }) }) @@ -538,12 +550,14 @@ var _ = Describe("Supply", func() { Expect(os.MkdirAll(depDir, 0755)).To(Succeed()) }) - Context("when requirements.txt was generated", func() { + Context("when requirements.txt was generated from uv.lock", func() { BeforeEach(func() { - const lockFileContent string = `{"_meta":{"sources":[]},"default":{"django":{"version":"==5.2"}}}` - Expect(os.WriteFile(filepath.Join(buildDir, "Pipfile"), []byte("some content"), 0644)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(buildDir, "Pipfile.lock"), []byte(lockFileContent), 0644)).To(Succeed()) - Expect(supplier.InstallPipEnv()).To(Succeed()) + Expect(os.WriteFile(filepath.Join(buildDir, "pyproject.toml"), []byte("[project]"), 0644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(buildDir, "uv.lock"), []byte("version = 1"), 0644)).To(Succeed()) + mockInstaller.EXPECT().InstallOnlyVersion("uv", gomock.Any()) + mockCommand.EXPECT().Execute(buildDir, gomock.Any(), gomock.Any(), "python", "-m", "pip", "install", "uv", "--no-index", gomock.Any(), "--target=/tmp/uv") + mockCommand.EXPECT().RunWithOutput(gomock.Any()).Return([]byte("django==5.2\n"), nil) + Expect(supplier.InstallUv()).To(Succeed()) }) It("moves it from the app directory to the deps dir", func() { @@ -556,6 +570,22 @@ var _ = Describe("Supply", func() { }) }) + Context("when requirements.txt was generated from Pipfile.lock", func() { + BeforeEach(func() { + const lockFileContent string = `{"_meta":{"sources":[]},"default":{"django":{"version":"==5.2"}}}` + Expect(os.WriteFile(filepath.Join(buildDir, "Pipfile"), []byte("some content"), 0644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(buildDir, "Pipfile.lock"), []byte(lockFileContent), 0644)).To(Succeed()) + Expect(supplier.InstallPipEnv()).To(Succeed()) + }) + + It("removes it from the app directory without keeping a copy", func() { + Expect(supplier.MoveGeneratedRequirementsTxt()).To(Succeed()) + + Expect(filepath.Join(buildDir, "requirements.txt")).NotTo(BeAnExistingFile()) + Expect(filepath.Join(depDir, "requirements.txt")).NotTo(BeAnExistingFile()) + }) + }) + Context("when requirements.txt was provided by the app", func() { BeforeEach(func() { Expect(os.WriteFile(filepath.Join(buildDir, "requirements.txt"), []byte("django"), 0644)).To(Succeed())