diff --git a/.gitignore b/.gitignore index d6393545..4c5fdd95 100644 --- a/.gitignore +++ b/.gitignore @@ -29,5 +29,6 @@ go.work.sum /.bin /bin /build +/linux/ .idea/ .DS_Store diff --git a/build.go b/build.go index 5d3bc507..25a8a7b2 100644 --- a/build.go +++ b/build.go @@ -12,6 +12,7 @@ import ( conda "github.com/paketo-buildpacks/python-packagers/pkg/packagers/conda" pipinstall "github.com/paketo-buildpacks/python-packagers/pkg/packagers/pip" pipenvinstall "github.com/paketo-buildpacks/python-packagers/pkg/packagers/pipenv" + pixiinstall "github.com/paketo-buildpacks/python-packagers/pkg/packagers/pixi" poetryinstall "github.com/paketo-buildpacks/python-packagers/pkg/packagers/poetry" uvinstall "github.com/paketo-buildpacks/python-packagers/pkg/packagers/uv" @@ -123,6 +124,21 @@ func Build( } else { return packit.BuildResult{}, packit.Fail.WithMessage("missing plan for: %s", entry.Name) } + case pixiinstall.PixiEnvPlanEntry: + if parameters, ok := buildParameters[pixiinstall.PixiEnvPlanEntry]; ok { + pixiResult, err := pixiinstall.Build( + parameters.(pixiinstall.PixiBuildParameters), + commonBuildParameters, + )(context) + + if err != nil { + return packit.BuildResult{}, err + } + + layers = append(layers, pixiResult.Layers...) + } else { + return packit.BuildResult{}, packit.Fail.WithMessage("missing plan for: %s", entry.Name) + } default: return packit.BuildResult{}, packit.Fail.WithMessage("unknown plan: %s", entry.Name) } diff --git a/detect.go b/detect.go index d96c7192..a78b08a3 100644 --- a/detect.go +++ b/detect.go @@ -15,6 +15,7 @@ import ( conda "github.com/paketo-buildpacks/python-packagers/pkg/packagers/conda" pipinstall "github.com/paketo-buildpacks/python-packagers/pkg/packagers/pip" pipenvinstall "github.com/paketo-buildpacks/python-packagers/pkg/packagers/pipenv" + pixiinstall "github.com/paketo-buildpacks/python-packagers/pkg/packagers/pixi" ) // Detect will return a packit.DetectFunc that will be invoked during the @@ -49,15 +50,6 @@ func Detect(logger scribe.Emitter) packit.DetectFunc { logger.Detail("%s", err) } - logger.Title("Checking for conda") - condaResult, err := conda.Detect()(context) - - if err == nil { - return condaResult, nil - } else { - logger.Detail("%s", err) - } - logger.Title("Checking for pipenv") pipenvResult, err := pipenvinstall.Detect( pipenvinstall.NewPipfileParser(), @@ -70,6 +62,24 @@ func Detect(logger scribe.Emitter) packit.DetectFunc { logger.Detail("%s", err) } + logger.Title("Checking for pixi") + pixiResult, err := pixiinstall.Detect()(context) + + if err == nil { + return pixiResult, nil + } else { + logger.Detail("%s", err) + } + + logger.Title("Checking for conda") + condaResult, err := conda.Detect()(context) + + if err == nil { + return condaResult, nil + } else { + logger.Detail("%s", err) + } + return packit.DetectResult{}, packit.Fail.WithMessage("No python packager manager related files found") } } diff --git a/integration/packagers/init_test.go b/integration/packagers/init_test.go index 20d4f7fd..7d2c7f70 100644 --- a/integration/packagers/init_test.go +++ b/integration/packagers/init_test.go @@ -123,5 +123,9 @@ func TestIntegration(t *testing.T) { suite("uv Offline", uvTestOffline) suite("uv Reused", uvTestReused) + // pixi + suite("pixi Default", pixiTestDefault) + suite("pixi Reused", pixiTestLayerReuse) + suite.Run(t) } diff --git a/integration/packagers/pixi_default_test.go b/integration/packagers/pixi_default_test.go new file mode 100644 index 00000000..b674a32f --- /dev/null +++ b/integration/packagers/pixi_default_test.go @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: Copyright (c) 2013-Present CloudFoundry.org Foundation, Inc. All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/paketo-buildpacks/occam" + "github.com/sclevine/spec" + + . "github.com/onsi/gomega" + . "github.com/paketo-buildpacks/occam/matchers" +) + +func pixiTestDefault(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + Eventually = NewWithT(t).Eventually + pack occam.Pack + docker occam.Docker + ) + + it.Before(func() { + pack = occam.NewPack() + docker = occam.NewDocker() + }) + + context("when building a simple app", func() { + var ( + image occam.Image + container occam.Container + name string + source string + ) + + it.Before(func() { + var err error + name, err = occam.RandomName() + Expect(err).NotTo(HaveOccurred()) + + source, err = occam.Source(filepath.Join("testdata", "pixi", "default_app")) + Expect(err).NotTo(HaveOccurred()) + }) + + it.After(func() { + Expect(docker.Container.Remove.Execute(container.ID)).To(Succeed()) + Expect(docker.Image.Remove.Execute(image.ID)).To(Succeed()) + Expect(docker.Volume.Remove.Execute(occam.CacheVolumeNames(name))).To(Succeed()) + Expect(os.RemoveAll(source)).To(Succeed()) + }) + + it("builds an oci image that has the correct behavior", func() { + var err error + + var logs fmt.Stringer + image, logs, err = pack.WithNoColor().Build. + WithPullPolicy("never"). + WithBuildpacks( + settings.Buildpacks.PythonInstallers.Online, + settings.Buildpacks.PythonPackagers.Online, + settings.Buildpacks.BuildPlan.Online, + ). + Execute(name, source) + Expect(err).NotTo(HaveOccurred(), logs.String()) + + container, err = docker.Container.Run. + WithEnv(map[string]string{"PORT": "8080"}). + WithPublish("8080"). + WithPublishAll(). + WithCommand("python app.py"). + Execute(image.ID) + Expect(err).NotTo(HaveOccurred()) + + Eventually(container).Should(Serve(ContainSubstring("Hello, world!")).OnPort(8080)) + }) + + context("validating SBOM", func() { + var ( + sbomDir string + ) + + it.Before(func() { + var err error + sbomDir, err = os.MkdirTemp("", "sbom") + Expect(err).NotTo(HaveOccurred()) + Expect(os.Chmod(sbomDir, os.ModePerm)).To(Succeed()) + + source, err = occam.Source(filepath.Join("testdata", "pixi", "default_app")) + Expect(err).NotTo(HaveOccurred()) + }) + + it.After(func() { + Expect(os.RemoveAll(sbomDir)).To(Succeed()) + }) + + it("writes SBOM files to the layer and label metadata", func() { + var err error + var logs fmt.Stringer + + image, logs, err = pack.WithNoColor().Build. + WithPullPolicy("never"). + WithBuildpacks( + settings.Buildpacks.PythonInstallers.Online, + settings.Buildpacks.PythonPackagers.Online, + settings.Buildpacks.BuildPlan.Online, + ). + WithEnv(map[string]string{ + "BP_LOG_LEVEL": "DEBUG", + }). + WithSBOMOutputDir(sbomDir). + Execute(name, source) + Expect(err).ToNot(HaveOccurred(), logs.String) + + container, err = docker.Container.Run. + WithCommand("python app.py"). + WithEnv(map[string]string{"PORT": "8080"}). + WithPublish("8080"). + Execute(image.ID) + Expect(err).ToNot(HaveOccurred()) + + Eventually(container).Should(BeAvailable()) + Eventually(container).Should(Serve(ContainSubstring("Hello, world!")).OnPort(8080)) + + Expect(logs).To(ContainLines( + fmt.Sprintf(" Generating SBOM for /layers/%s/pixi-env", strings.ReplaceAll(buildpackInfo.Buildpack.ID, "/", "_")), + MatchRegexp(` Completed in \d+(\.?\d+)*`), + )) + Expect(logs).To(ContainLines( + " Writing SBOM in the following format(s):", + " application/vnd.cyclonedx+json", + " application/spdx+json", + " application/vnd.syft+json", + )) + + // check that all required SBOM files are present + Expect(filepath.Join(sbomDir, "sbom", "launch", strings.ReplaceAll(buildpackInfo.Buildpack.ID, "/", "_"), "pixi-env", "sbom.cdx.json")).To(BeARegularFile()) + Expect(filepath.Join(sbomDir, "sbom", "launch", strings.ReplaceAll(buildpackInfo.Buildpack.ID, "/", "_"), "pixi-env", "sbom.spdx.json")).To(BeARegularFile()) + Expect(filepath.Join(sbomDir, "sbom", "launch", strings.ReplaceAll(buildpackInfo.Buildpack.ID, "/", "_"), "pixi-env", "sbom.syft.json")).To(BeARegularFile()) + + // check an SBOM file to make sure it has an entry for a dependency from pixi.toml + contents, err := os.ReadFile(filepath.Join(sbomDir, "sbom", "launch", strings.ReplaceAll(buildpackInfo.Buildpack.ID, "/", "_"), "pixi-env", "sbom.cdx.json")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(contents)).To(ContainSubstring(`"name": "flask"`)) + }) + }) + }) +} diff --git a/integration/packagers/pixi_layer_reuse_test.go b/integration/packagers/pixi_layer_reuse_test.go new file mode 100644 index 00000000..24b1c16d --- /dev/null +++ b/integration/packagers/pixi_layer_reuse_test.go @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: Copyright (c) 2013-Present CloudFoundry.org Foundation, Inc. All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/paketo-buildpacks/occam" + "github.com/sclevine/spec" + + . "github.com/onsi/gomega" + . "github.com/paketo-buildpacks/occam/matchers" +) + +func pixiTestLayerReuse(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + Eventually = NewWithT(t).Eventually + pack occam.Pack + docker occam.Docker + ) + + it.Before(func() { + pack = occam.NewPack() + docker = occam.NewDocker() + }) + + context("when rebuilding an app", func() { + var ( + firstImage occam.Image + secondImage occam.Image + firstContainer occam.Container + secondContainer occam.Container + name string + source string + imagesMap map[string]interface{} + containerMap map[string]interface{} + ) + + it.Before(func() { + var err error + name, err = occam.RandomName() + Expect(err).NotTo(HaveOccurred()) + + imagesMap = map[string]interface{}{} + containerMap = map[string]interface{}{} + + source, err = occam.Source(filepath.Join("testdata", "pixi", "default_app")) + Expect(err).NotTo(HaveOccurred()) + }) + + it.After(func() { + for containerID := range containerMap { + Expect(docker.Container.Remove.Execute(containerID)).To(Succeed()) + } + for imageID := range imagesMap { + Expect(docker.Image.Remove.Execute(imageID)).To(Succeed()) + } + + Expect(docker.Volume.Remove.Execute(occam.CacheVolumeNames(name))).To(Succeed()) + Expect(os.RemoveAll(source)).To(Succeed()) + }) + + it("reuses the cached packages layer", func() { + var err error + + var logs fmt.Stringer + firstImage, logs, err = pack.WithNoColor().Build. + WithPullPolicy("never"). + WithBuildpacks( + settings.Buildpacks.PythonInstallers.Online, + settings.Buildpacks.PythonPackagers.Online, + settings.Buildpacks.BuildPlan.Online, + ). + Execute(name, source) + Expect(err).NotTo(HaveOccurred(), logs.String()) + + imagesMap[firstImage.ID] = nil + + firstContainer, err = docker.Container.Run. + WithEnv(map[string]string{"PORT": "8080"}). + WithPublish("8080"). + WithPublishAll(). + WithCommand("python app.py"). + Execute(firstImage.ID) + Expect(err).NotTo(HaveOccurred()) + + containerMap[firstContainer.ID] = nil + + Eventually(firstContainer).Should(Serve(ContainSubstring("Hello, world!")).OnPort(8080)) + + secondImage, logs, err = pack.WithNoColor().Build. + WithPullPolicy("never"). + WithBuildpacks( + settings.Buildpacks.PythonInstallers.Online, + settings.Buildpacks.PythonPackagers.Online, + settings.Buildpacks.BuildPlan.Online, + ). + Execute(name, source) + Expect(err).NotTo(HaveOccurred(), logs.String()) + + imagesMap[secondImage.ID] = nil + + Expect(secondImage.Buildpacks[1].Key).To(Equal(buildpackInfo.Buildpack.ID)) + Expect(secondImage.Buildpacks[1].Layers["pixi-env"].SHA).To(Equal(firstImage.Buildpacks[1].Layers["pixi-env"].SHA)) + Expect(secondImage.Buildpacks[1].Layers["pixi-env"].Metadata["lockfile-sha"]).To(Equal(firstImage.Buildpacks[1].Layers["pixi-env"].Metadata["lockfile-sha"])) + + secondContainer, err = docker.Container.Run. + WithEnv(map[string]string{"PORT": "8080"}). + WithPublish("8080"). + WithPublishAll(). + WithCommand("python app.py"). + Execute(secondImage.ID) + Expect(err).NotTo(HaveOccurred()) + + containerMap[secondContainer.ID] = nil + + Eventually(secondContainer).Should(Serve(ContainSubstring("Hello, world!")).OnPort(8080)) + }) + }) +} diff --git a/integration/packagers/testdata/conda/default_app/environment.yml b/integration/packagers/testdata/conda/default_app/environment.yml index 79ef42e1..e8241d18 100644 --- a/integration/packagers/testdata/conda/default_app/environment.yml +++ b/integration/packagers/testdata/conda/default_app/environment.yml @@ -1,4 +1,3 @@ dependencies: -- python=2.7 -- click=7.1 +- python=3.10 - flask diff --git a/integration/packagers/testdata/conda/vendored_app/package-list.txt.org b/integration/packagers/testdata/conda/vendored_app/package-list.txt.org deleted file mode 100644 index e0408128..00000000 --- a/integration/packagers/testdata/conda/vendored_app/package-list.txt.org +++ /dev/null @@ -1,33 +0,0 @@ -# This file may be used to create an environment using: -# $ conda create --name --file -# platform: linux-64 -# created-by: conda 24.9.1 -_libgcc_mutex=0.1=main -_openmp_mutex=5.1=1_gnu -blinker=1.9.0=py310h06a4308_0 -bzip2=1.0.8=h5eee18b_6 -ca-certificates=2025.2.25=h06a4308_0 -click=8.1.8=py310h06a4308_0 -flask=3.1.0=py310h06a4308_0 -itsdangerous=2.2.0=py310h06a4308_0 -jinja2=3.1.6=py310h06a4308_0 -ld_impl_linux-64=2.40=h12ee557_0 -libffi=3.4.4=h6a678d5_1 -libgcc-ng=11.2.0=h1234567_1 -libgomp=11.2.0=h1234567_1 -libstdcxx-ng=11.2.0=h1234567_1 -libuuid=1.41.5=h5eee18b_0 -markupsafe=3.0.2=py310h5eee18b_0 -ncurses=6.4=h6a678d5_0 -openssl=3.0.16=h5eee18b_0 -pip=25.0=py310h06a4308_0 -python=3.10.16=he870216_1 -readline=8.2=h5eee18b_0 -setuptools=75.8.0=py310h06a4308_0 -sqlite=3.45.3=h5eee18b_0 -tk=8.6.14=h39e8969_0 -tzdata=2025a=h04d1e81_0 -werkzeug=3.1.3=py310h06a4308_0 -wheel=0.45.1=py310h06a4308_0 -xz=5.6.4=h5eee18b_1 -zlib=1.2.13=h5eee18b_1 diff --git a/integration/packagers/testdata/pixi/REUSE.toml b/integration/packagers/testdata/pixi/REUSE.toml new file mode 100644 index 00000000..d714b7ef --- /dev/null +++ b/integration/packagers/testdata/pixi/REUSE.toml @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2026 Idiap Research Institute +# +# SPDX-License-Identifier: CC0-1.0 + +version = 1 + +[[annotations]] +path = [ + "default_app/**", +] +precedence = "override" +SPDX-FileCopyrightText = "2026 Idiap Research Institute " +SPDX-License-Identifier = "Apache-2.0" diff --git a/integration/packagers/testdata/pixi/default_app/.gitattributes b/integration/packagers/testdata/pixi/default_app/.gitattributes new file mode 100644 index 00000000..f9c2be2b --- /dev/null +++ b/integration/packagers/testdata/pixi/default_app/.gitattributes @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2026 Idiap Research Institute +# +# SPDX-License-Identifier: Apache-2.0 + +# SCM syntax highlighting & preventing 3-way merges +pixi.lock merge=binary linguist-language=YAML linguist-generated=true -diff diff --git a/integration/packagers/testdata/pixi/default_app/.gitignore b/integration/packagers/testdata/pixi/default_app/.gitignore new file mode 100644 index 00000000..a26c71c4 --- /dev/null +++ b/integration/packagers/testdata/pixi/default_app/.gitignore @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: 2026 Idiap Research Institute +# +# SPDX-License-Identifier: Apache-2.0 + +# pixi environments +.pixi/* +!.pixi/config.toml diff --git a/integration/packagers/testdata/pixi/default_app/app.py b/integration/packagers/testdata/pixi/default_app/app.py new file mode 100644 index 00000000..9b009f7c --- /dev/null +++ b/integration/packagers/testdata/pixi/default_app/app.py @@ -0,0 +1,19 @@ +import os +import sys + +from flask import Flask + +app = Flask(__name__) + + +@app.route('/') +def root(): + python_version = sys.version + return "Hello, world!\nUsing python: " + python_version + "\n" + + +if __name__ == '__main__': + # Get port from environment variable or choose 9099 as local default + port = int(os.getenv("PORT", 8080)) + # Run the app, listening on all IPs with our chosen port number + app.run(host='0.0.0.0', port=port, debug=True) diff --git a/integration/packagers/testdata/pixi/default_app/pixi.lock b/integration/packagers/testdata/pixi/default_app/pixi.lock new file mode 100644 index 00000000..aa9c3391 --- /dev/null +++ b/integration/packagers/testdata/pixi/default_app/pixi.lock @@ -0,0 +1,659 @@ +version: 6 +environments: + default: + channels: + - url: https://conda.anaconda.org/conda-forge/ + options: + pypi-prerelease-mode: if-necessary-or-explicit + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/blinker-1.9.0-pyhff2d567_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyh8f84b5b_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/flask-3.1.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.2-h33c6efd_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/itsdangerous-2.2.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_101.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.4-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_18.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_18.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.2-hf4e2dac_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_18.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py313h3dea7bd_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.1-h35e630c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.13.12-hc97d973_100_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/werkzeug-3.1.5-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + linux-aarch64: + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/blinker-1.9.0-pyhff2d567_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyh8f84b5b_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/flask-3.1.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/icu-78.2-hb1525cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/itsdangerous-2.2.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.45.1-default_h1979696_101.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libexpat-2.7.4-hfae3067_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libffi-3.5.2-h376a255_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-15.2.0-h8acb6b2_18.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-15.2.0-h8acb6b2_18.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/liblzma-5.8.2-he30d5cf_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libmpdec-4.0.0-he30d5cf_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.51.2-h10b116e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-15.2.0-hef695bb_18.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libuuid-2.41.3-h1022ec0_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.1-h86ecc28_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/markupsafe-3.0.3-py313hfa222a2_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.5-ha32ae93_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.6.1-h546c87b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.13.12-h4c0d347_100_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.3-hb682ff5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-noxft_h0dc03b3_103.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/werkzeug-3.1.5-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-h85ac4a6_6.conda +packages: +- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + build_number: 20 + sha256: 1dd3fffd892081df9726d7eb7e0dea6198962ba775bd88842135a4ddb4deb3c9 + md5: a9f577daf3de00bca7c3c76c0ecbd1de + depends: + - __glibc >=2.17,<3.0.a0 + - libgomp >=7.5.0 + constrains: + - openmp_impl <0.0a0 + license: BSD-3-Clause + license_family: BSD + size: 28948 + timestamp: 1770939786096 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-20_gnu.conda + build_number: 20 + sha256: a2527b1d81792a0ccd2c05850960df119c2b6d8f5fdec97f2db7d25dc23b1068 + md5: 468fd3bb9e1f671d36c2cbc677e56f1d + depends: + - libgomp >=7.5.0 + constrains: + - openmp_impl <0.0a0 + license: BSD-3-Clause + license_family: BSD + size: 28926 + timestamp: 1770939656741 +- conda: https://conda.anaconda.org/conda-forge/noarch/blinker-1.9.0-pyhff2d567_0.conda + sha256: f7efd22b5c15b400ed84a996d777b6327e5c402e79e3c534a7e086236f1eb2dc + md5: 42834439227a4551b939beeeb8a4b085 + depends: + - python >=3.9 + license: MIT + license_family: MIT + size: 13934 + timestamp: 1731096548765 +- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda + sha256: 0b75d45f0bba3e95dc693336fa51f40ea28c980131fec438afb7ce6118ed05f6 + md5: d2ffd7602c02f2b316fd921d39876885 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: bzip2-1.0.6 + license_family: BSD + size: 260182 + timestamp: 1771350215188 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_9.conda + sha256: b3495077889dde6bb370938e7db82be545c73e8589696ad0843a32221520ad4c + md5: 840d8fc0d7b3209be93080bc20e07f2d + depends: + - libgcc >=14 + license: bzip2-1.0.6 + license_family: BSD + size: 192412 + timestamp: 1771350241232 +- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-hbd8a1cb_0.conda + sha256: b5974ec9b50e3c514a382335efa81ed02b05906849827a34061c496f4defa0b2 + md5: bddacf101bb4dd0e51811cb69c7790e2 + depends: + - __unix + license: ISC + size: 146519 + timestamp: 1767500828366 +- conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyh8f84b5b_1.conda + sha256: 38cfe1ee75b21a8361c8824f5544c3866f303af1762693a178266d7f198e8715 + md5: ea8a6c3256897cc31263de9f455e25d9 + depends: + - python >=3.10 + - __unix + - python + license: BSD-3-Clause + license_family: BSD + size: 97676 + timestamp: 1764518652276 +- conda: https://conda.anaconda.org/conda-forge/noarch/flask-3.1.2-pyhd8ed1ab_0.conda + sha256: 8a97eba37e0723720706d4636cc89c6b07eea1b7cc66fd8994fa8983a81ed988 + md5: ba67a9febeda36948fee26a3dec3d914 + depends: + - blinker >=1.9.0 + - click >=8.1.3 + - importlib-metadata >=3.6.0 + - itsdangerous >=2.2.0 + - jinja2 >=3.1.2 + - markupsafe >=2.1.1 + - python >=3.9 + - werkzeug >=3.1.0 + license: BSD-3-Clause + license_family: BSD + size: 82438 + timestamp: 1755674743256 +- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.2-h33c6efd_0.conda + sha256: 142a722072fa96cf16ff98eaaf641f54ab84744af81754c292cb81e0881c0329 + md5: 186a18e3ba246eccfc7cff00cd19a870 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + size: 12728445 + timestamp: 1767969922681 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/icu-78.2-hb1525cb_0.conda + sha256: 09f7f9213eb68e7e4291cd476e72b37f3ded99ed957528567f32f5ba6b611043 + md5: 15b35dc33e185e7d2aac1cfcd6778627 + depends: + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + size: 12852963 + timestamp: 1767975394622 +- conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda + sha256: c18ab120a0613ada4391b15981d86ff777b5690ca461ea7e9e49531e8f374745 + md5: 63ccfdc3a3ce25b027b8767eb722fca8 + depends: + - python >=3.9 + - zipp >=3.20 + - python + license: Apache-2.0 + license_family: APACHE + size: 34641 + timestamp: 1747934053147 +- conda: https://conda.anaconda.org/conda-forge/noarch/itsdangerous-2.2.0-pyhd8ed1ab_1.conda + sha256: 1684b7b16eec08efef5302ce298c606b163c18272b69a62b666fbaa61516f170 + md5: 7ac5f795c15f288984e32add616cdc59 + depends: + - python >=3.9 + license: BSD-3-Clause + license_family: BSD + size: 19180 + timestamp: 1733308353037 +- conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda + sha256: fc9ca7348a4f25fed2079f2153ecdcf5f9cf2a0bc36c4172420ca09e1849df7b + md5: 04558c96691bed63104678757beb4f8d + depends: + - markupsafe >=2.0 + - python >=3.10 + - python + license: BSD-3-Clause + license_family: BSD + size: 120685 + timestamp: 1764517220861 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_101.conda + sha256: 565941ac1f8b0d2f2e8f02827cbca648f4d18cd461afc31f15604cd291b5c5f3 + md5: 12bd9a3f089ee6c9266a37dab82afabd + depends: + - __glibc >=2.17,<3.0.a0 + - zstd >=1.5.7,<1.6.0a0 + constrains: + - binutils_impl_linux-64 2.45.1 + license: GPL-3.0-only + license_family: GPL + size: 725507 + timestamp: 1770267139900 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.45.1-default_h1979696_101.conda + sha256: 44527364aa333be631913451c32eb0cae1e09343827e9ce3ccabd8d962584226 + md5: 35b2ae7fadf364b8e5fb8185aaeb80e5 + depends: + - zstd >=1.5.7,<1.6.0a0 + constrains: + - binutils_impl_linux-aarch64 2.45.1 + license: GPL-3.0-only + license_family: GPL + size: 875924 + timestamp: 1770267209884 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.4-hecca717_0.conda + sha256: d78f1d3bea8c031d2f032b760f36676d87929b18146351c4464c66b0869df3f5 + md5: e7f7ce06ec24cfcfb9e36d28cf82ba57 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - expat 2.7.4.* + license: MIT + license_family: MIT + size: 76798 + timestamp: 1771259418166 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libexpat-2.7.4-hfae3067_0.conda + sha256: 995ce3ad96d0f4b5ed6296b051a0d7b6377718f325bc0e792fbb96b0e369dad7 + md5: 57f3b3da02a50a1be2a6fe847515417d + depends: + - libgcc >=14 + constrains: + - expat 2.7.4.* + license: MIT + license_family: MIT + size: 76564 + timestamp: 1771259530958 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + sha256: 31f19b6a88ce40ebc0d5a992c131f57d919f73c0b92cd1617a5bec83f6e961e6 + md5: a360c33a5abe61c07959e449fa1453eb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + size: 58592 + timestamp: 1769456073053 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libffi-3.5.2-h376a255_0.conda + sha256: 3df4c539449aabc3443bbe8c492c01d401eea894603087fca2917aa4e1c2dea9 + md5: 2f364feefb6a7c00423e80dcb12db62a + depends: + - libgcc >=14 + license: MIT + license_family: MIT + size: 55952 + timestamp: 1769456078358 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_18.conda + sha256: faf7d2017b4d718951e3a59d081eb09759152f93038479b768e3d612688f83f5 + md5: 0aa00f03f9e39fb9876085dee11a85d4 + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + constrains: + - libgcc-ng ==15.2.0=*_18 + - libgomp 15.2.0 he0feb66_18 + license: GPL-3.0-only WITH GCC-exception-3.1 + size: 1041788 + timestamp: 1771378212382 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-15.2.0-h8acb6b2_18.conda + sha256: 43df385bedc1cab11993c4369e1f3b04b4ca5d0ea16cba6a0e7f18dbc129fcc9 + md5: 552567ea2b61e3a3035759b2fdb3f9a6 + depends: + - _openmp_mutex >=4.5 + constrains: + - libgcc-ng ==15.2.0=*_18 + - libgomp 15.2.0 h8acb6b2_18 + license: GPL-3.0-only WITH GCC-exception-3.1 + size: 622900 + timestamp: 1771378128706 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_18.conda + sha256: 21337ab58e5e0649d869ab168d4e609b033509de22521de1bfed0c031bfc5110 + md5: 239c5e9546c38a1e884d69effcf4c882 + depends: + - __glibc >=2.17,<3.0.a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + size: 603262 + timestamp: 1771378117851 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-15.2.0-h8acb6b2_18.conda + sha256: fc716f11a6a8525e27a5d332ef6a689210b0d2a4dd1133edc0f530659aa9faa6 + md5: 4faa39bf919939602e594253bd673958 + license: GPL-3.0-only WITH GCC-exception-3.1 + size: 588060 + timestamp: 1771378040807 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda + sha256: 755c55ebab181d678c12e49cced893598f2bab22d582fbbf4d8b83c18be207eb + md5: c7c83eecbb72d88b940c249af56c8b17 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - xz 5.8.2.* + license: 0BSD + size: 113207 + timestamp: 1768752626120 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/liblzma-5.8.2-he30d5cf_0.conda + sha256: 843c46e20519651a3e357a8928352b16c5b94f4cd3d5481acc48be2e93e8f6a3 + md5: 96944e3c92386a12755b94619bae0b35 + depends: + - libgcc >=14 + constrains: + - xz 5.8.2.* + license: 0BSD + size: 125916 + timestamp: 1768754941722 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + sha256: fe171ed5cf5959993d43ff72de7596e8ac2853e9021dec0344e583734f1e0843 + md5: 2c21e66f50753a083cbe6b80f38268fa + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-2-Clause + license_family: BSD + size: 92400 + timestamp: 1769482286018 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libmpdec-4.0.0-he30d5cf_1.conda + sha256: 57c0dd12d506e84541c4e877898bd2a59cca141df493d34036f18b2751e0a453 + md5: 7b9813e885482e3ccb1fa212b86d7fd0 + depends: + - libgcc >=14 + license: BSD-2-Clause + license_family: BSD + size: 114056 + timestamp: 1769482343003 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.2-hf4e2dac_0.conda + sha256: 04596fcee262a870e4b7c9807224680ff48d4d0cc0dac076a602503d3dc6d217 + md5: da5be73701eecd0e8454423fd6ffcf30 + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=78.2,<79.0a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + license: blessing + size: 942808 + timestamp: 1768147973361 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.51.2-h10b116e_0.conda + sha256: 5f8230ccaf9ffaab369adc894ef530699e96111dac0a8ff9b735a871f8ba8f8b + md5: 4e3ba0d5d192f99217b85f07a0761e64 + depends: + - icu >=78.2,<79.0a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + license: blessing + size: 944688 + timestamp: 1768147991301 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_18.conda + sha256: 78668020064fdaa27e9ab65cd2997e2c837b564ab26ce3bf0e58a2ce1a525c6e + md5: 1b08cd684f34175e4514474793d44bcb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc 15.2.0 he0feb66_18 + constrains: + - libstdcxx-ng ==15.2.0=*_18 + license: GPL-3.0-only WITH GCC-exception-3.1 + size: 5852330 + timestamp: 1771378262446 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-15.2.0-hef695bb_18.conda + sha256: 31fdb9ffafad106a213192d8319b9f810e05abca9c5436b60e507afb35a6bc40 + md5: f56573d05e3b735cb03efeb64a15f388 + depends: + - libgcc 15.2.0 h8acb6b2_18 + constrains: + - libstdcxx-ng ==15.2.0=*_18 + license: GPL-3.0-only WITH GCC-exception-3.1 + size: 5541411 + timestamp: 1771378162499 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda + sha256: 1a7539cfa7df00714e8943e18de0b06cceef6778e420a5ee3a2a145773758aee + md5: db409b7c1720428638e7c0d509d3e1b5 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + size: 40311 + timestamp: 1766271528534 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libuuid-2.41.3-h1022ec0_0.conda + sha256: c37a8e89b700646f3252608f8368e7eb8e2a44886b92776e57ad7601fc402a11 + md5: cf2861212053d05f27ec49c3784ff8bb + depends: + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + size: 43453 + timestamp: 1766271546875 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda + sha256: d4bfe88d7cb447768e31650f06257995601f89076080e76df55e3112d4e47dc4 + md5: edb0dca6bc32e4f4789199455a1dbeb8 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + constrains: + - zlib 1.3.1 *_2 + license: Zlib + license_family: Other + size: 60963 + timestamp: 1727963148474 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.1-h86ecc28_2.conda + sha256: 5a2c1eeef69342e88a98d1d95bff1603727ab1ff4ee0e421522acd8813439b84 + md5: 08aad7cbe9f5a6b460d0976076b6ae64 + depends: + - libgcc >=13 + constrains: + - zlib 1.3.1 *_2 + license: Zlib + license_family: Other + size: 66657 + timestamp: 1727963199518 +- conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py313h3dea7bd_0.conda + sha256: a530a411bdaaf0b1e4de8869dfaca46cb07407bc7dc0702a9e231b0e5ce7ca85 + md5: c14389156310b8ed3520d84f854be1ee + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - python >=3.13,<3.14.0a0 + - python_abi 3.13.* *_cp313 + constrains: + - jinja2 >=3.0.0 + license: BSD-3-Clause + license_family: BSD + size: 25909 + timestamp: 1759055357045 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/markupsafe-3.0.3-py313hfa222a2_0.conda + sha256: c03eb8f5a4659ce31e698a328372f6b0357644d557ea0dc01fe0c5897c231c48 + md5: 59fc93a010d6e8a08a4fa32424d86a82 + depends: + - libgcc >=14 + - python >=3.13,<3.14.0a0 + - python_abi 3.13.* *_cp313 + constrains: + - jinja2 >=3.0.0 + license: BSD-3-Clause + license_family: BSD + size: 26403 + timestamp: 1759056219797 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda + sha256: 3fde293232fa3fca98635e1167de6b7c7fda83caf24b9d6c91ec9eefb4f4d586 + md5: 47e340acb35de30501a76c7c799c41d7 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: X11 AND BSD-3-Clause + size: 891641 + timestamp: 1738195959188 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.5-ha32ae93_3.conda + sha256: 91cfb655a68b0353b2833521dc919188db3d8a7f4c64bea2c6a7557b24747468 + md5: 182afabe009dc78d8b73100255ee6868 + depends: + - libgcc >=13 + license: X11 AND BSD-3-Clause + size: 926034 + timestamp: 1738196018799 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.1-h35e630c_1.conda + sha256: 44c877f8af015332a5d12f5ff0fb20ca32f896526a7d0cdb30c769df1144fb5c + md5: f61eb8cd60ff9057122a3d338b99c00f + depends: + - __glibc >=2.17,<3.0.a0 + - ca-certificates + - libgcc >=14 + license: Apache-2.0 + license_family: Apache + size: 3164551 + timestamp: 1769555830639 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.6.1-h546c87b_1.conda + sha256: 7f8048c0e75b2620254218d72b4ae7f14136f1981c5eb555ef61645a9344505f + md5: 25f5885f11e8b1f075bccf4a2da91c60 + depends: + - ca-certificates + - libgcc >=14 + license: Apache-2.0 + license_family: Apache + size: 3692030 + timestamp: 1769557678657 +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.13.12-hc97d973_100_cp313.conda + build_number: 100 + sha256: 8a08fe5b7cb5a28aa44e2994d18dbf77f443956990753a4ca8173153ffb6eb56 + md5: 4c875ed0e78c2d407ec55eadffb8cf3d + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-64 >=2.36.1 + - libexpat >=2.7.3,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - liblzma >=5.8.2,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.51.2,<4.0a0 + - libuuid >=2.41.3,<3.0a0 + - libzlib >=1.3.1,<2.0a0 + - ncurses >=6.5,<7.0a0 + - openssl >=3.5.5,<4.0a0 + - python_abi 3.13.* *_cp313 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + license: Python-2.0 + size: 37364553 + timestamp: 1770272309861 + python_site_packages_path: lib/python3.13/site-packages +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.13.12-h4c0d347_100_cp313.conda + build_number: 100 + sha256: a6bdf48a245d70526b4e6a277a4b344ec3f7c787b358e5377d544ac9a303c111 + md5: 732a86d6786402b95e1dc68c32022500 + depends: + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-aarch64 >=2.36.1 + - libexpat >=2.7.3,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - liblzma >=5.8.2,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.51.2,<4.0a0 + - libuuid >=2.41.3,<3.0a0 + - libzlib >=1.3.1,<2.0a0 + - ncurses >=6.5,<7.0a0 + - openssl >=3.5.5,<4.0a0 + - python_abi 3.13.* *_cp313 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + license: Python-2.0 + size: 33986700 + timestamp: 1770270924894 + python_site_packages_path: lib/python3.13/site-packages +- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + build_number: 8 + sha256: 210bffe7b121e651419cb196a2a63687b087497595c9be9d20ebe97dd06060a7 + md5: 94305520c52a4aa3f6c2b1ff6008d9f8 + constrains: + - python 3.13.* *_cp313 + license: BSD-3-Clause + license_family: BSD + size: 7002 + timestamp: 1752805902938 +- conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + sha256: 12ffde5a6f958e285aa22c191ca01bbd3d6e710aa852e00618fa6ddc59149002 + md5: d7d95fc8287ea7bf33e0e7116d2b95ec + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + size: 345073 + timestamp: 1765813471974 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.3-hb682ff5_0.conda + sha256: fe695f9d215e9a2e3dd0ca7f56435ab4df24f5504b83865e3d295df36e88d216 + md5: 3d49cad61f829f4f0e0611547a9cda12 + depends: + - libgcc >=14 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + size: 357597 + timestamp: 1765815673644 +- conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda + sha256: cafeec44494f842ffeca27e9c8b0c27ed714f93ac77ddadc6aaf726b5554ebac + md5: cffd3bdd58090148f4cfcd831f4b26ab + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + constrains: + - xorg-libx11 >=1.8.12,<2.0a0 + license: TCL + license_family: BSD + size: 3301196 + timestamp: 1769460227866 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-noxft_h0dc03b3_103.conda + sha256: e25c314b52764219f842b41aea2c98a059f06437392268f09b03561e4f6e5309 + md5: 7fc6affb9b01e567d2ef1d05b84aa6ed + depends: + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + constrains: + - xorg-libx11 >=1.8.12,<2.0a0 + license: TCL + license_family: BSD + size: 3368666 + timestamp: 1769464148928 +- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + sha256: 1d30098909076af33a35017eed6f2953af1c769e273a0626a04722ac4acaba3c + md5: ad659d0a2b3e47e38d829aa8cad2d610 + license: LicenseRef-Public-Domain + size: 119135 + timestamp: 1767016325805 +- conda: https://conda.anaconda.org/conda-forge/noarch/werkzeug-3.1.5-pyhcf101f3_0.conda + sha256: 3ef418943ef14939a4bbc5157f31db2d6a7a025a3bfd7b4aa5a29034ba96e42e + md5: 784e86b857b809955635175881a9a418 + depends: + - markupsafe >=2.1.1 + - python >=3.10 + - python + license: BSD-3-Clause + license_family: BSD + size: 257059 + timestamp: 1767946313110 +- conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda + sha256: b4533f7d9efc976511a73ef7d4a2473406d7f4c750884be8e8620b0ce70f4dae + md5: 30cd29cb87d819caead4d55184c1d115 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + size: 24194 + timestamp: 1764460141901 +- conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + sha256: 68f0206ca6e98fea941e5717cec780ed2873ffabc0e1ed34428c061e2c6268c7 + md5: 4a13eeac0b5c8e5b8ab496e6c4ddd829 + depends: + - __glibc >=2.17,<3.0.a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + size: 601375 + timestamp: 1764777111296 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-h85ac4a6_6.conda + sha256: 569990cf12e46f9df540275146da567d9c618c1e9c7a0bc9d9cfefadaed20b75 + md5: c3655f82dcea2aa179b291e7099c1fcc + depends: + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + size: 614429 + timestamp: 1764777145593 diff --git a/integration/packagers/testdata/pixi/default_app/pixi.toml b/integration/packagers/testdata/pixi/default_app/pixi.toml new file mode 100644 index 00000000..81d40ec7 --- /dev/null +++ b/integration/packagers/testdata/pixi/default_app/pixi.toml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2026 Idiap Research Institute +# +# SPDX-License-Identifier: Apache-2.0 + +[workspace] +authors = ["Samuel Gaist "] +channels = ["conda-forge"] +name = "default_app" +platforms = ["linux-aarch64", "linux-64"] +version = "0.1.0" + +[tasks] + +[dependencies] +flask = ">=3.1.2,<4" diff --git a/integration/packagers/testdata/pixi/default_app/plan.toml b/integration/packagers/testdata/pixi/default_app/plan.toml new file mode 100644 index 00000000..21bfd4e8 --- /dev/null +++ b/integration/packagers/testdata/pixi/default_app/plan.toml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2026 Idiap Research Institute +# +# SPDX-License-Identifier: Apache-2.0 + +[[requires]] +name = "pixi-environment" + +[requires.metadata] +launch = true diff --git a/pkg/packagers/pixi/README.md b/pkg/packagers/pixi/README.md new file mode 100644 index 00000000..ae81cb9b --- /dev/null +++ b/pkg/packagers/pixi/README.md @@ -0,0 +1,57 @@ + + +# Sub package for pixi environment installation + +This sub package runs commands to install a pixi environment. It installs the +pixi environment into a layer which makes it available for subsequent +buildpacks and in the final running container. + +## Behavior + +This sub package participates when there is an `pixi.lock` or +`pixi.toml` file in the app directory. + +The buildpack will do the following: + +* At build time: + - Requires that pixi has already been installed in the build container + - Install the pixi environment and stores it in a layer + - Reuses the cached pixi environment layer from a previous build if neither + the project nor lock file has changed. +* At run time: + - Does nothing + +## Integration + +This sub package provides `pixi-environment` as a dependency. Downstream +buildpacks can require the `pixi-environment` dependency by generating a +[Build Plan TOML] +(https://github.com/buildpacks/spec/blob/master/buildpack.md#build-plan-toml) +file that looks like the following: + +```toml +[[requires]] +# The name of the Pixi Install dependency is "pixi-environment". This value is +# considered part of the public API for the buildpack and will not change +# without a plan for deprecation. +name = "pixi-environment" + +# The Pixi Install buildpack supports some non-required metadata options. +[requires.metadata] + +# Setting the build flag to true will ensure that the pixi environment +# layer is available for subsequent buildpacks during their build phase. +# If you are writing a buildpack that needs the pixi environment +# during its build process, this flag should be set to true. +build = true + +# Setting the launch flag to true will ensure that the pixi environment is +# available to the running application. If you are writing an application +# that needs to use the pixi environment at runtime, this flag should be set to true. +launch = true +``` diff --git a/pkg/packagers/pixi/build.go b/pkg/packagers/pixi/build.go new file mode 100644 index 00000000..41adc84a --- /dev/null +++ b/pkg/packagers/pixi/build.go @@ -0,0 +1,141 @@ +// SPDX-FileCopyrightText: © 2026 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package pixiinstall + +import ( + "os" + "path/filepath" + "time" + + "github.com/paketo-buildpacks/packit/v2" + "github.com/paketo-buildpacks/packit/v2/draft" + "github.com/paketo-buildpacks/packit/v2/fs" + "github.com/paketo-buildpacks/packit/v2/sbom" + + pythonpackagers "github.com/paketo-buildpacks/python-packagers/pkg/packagers/common" +) + +//go:generate faux --interface Runner --output fakes/runner.go +//go:generate faux --interface SBOMGenerator --output fakes/sbom_generator.go + +// Runner defines the interface for setting up the pixi environment. +type Runner interface { + Execute(pixiEnvPath string, pixiCachePath string, workingDir string) error + ShouldRun(workingDir string, metadata map[string]interface{}) (bool, string, error) +} + +type SBOMGenerator interface { + Generate(dir string) (sbom.SBOM, error) +} + +// PixiBuildParameters encapsulates the pixi specific parameters for the +// Build function +type PixiBuildParameters struct { + Runner Runner +} + +// Build will return a packit.BuildFunc that will be invoked during the build +// phase of the buildpack lifecycle. +// +// Build creates the pixi environment and stores the result in a layer. It may +// reuse the environment layer from a previous build, depending on conditions +// determined by the runner. +func Build( + buildParameters PixiBuildParameters, + parameters pythonpackagers.CommonBuildParameters, +) packit.BuildFunc { + return func(context packit.BuildContext) (packit.BuildResult, error) { + runner := buildParameters.Runner + sbomGenerator := parameters.SbomGenerator + clock := parameters.Clock + logger := parameters.Logger + + logger.Title("%s %s", context.BuildpackInfo.Name, context.BuildpackInfo.Version) + + pixiLayer, err := context.Layers.Get(PixiEnvLayer) + if err != nil { + return packit.BuildResult{}, err + } + + pixiCacheLayer, err := context.Layers.Get(PixiEnvCache) + if err != nil { + return packit.BuildResult{}, err + } + + run, sha, err := runner.ShouldRun(context.WorkingDir, pixiLayer.Metadata) + if err != nil { + return packit.BuildResult{}, err + } + + if run { + pixiLayer, err = pixiLayer.Reset() + if err != nil { + return packit.BuildResult{}, err + } + + logger.Process("Executing build process") + + duration, err := clock.Measure(func() error { + return runner.Execute(pixiLayer.Path, pixiCacheLayer.Path, context.WorkingDir) + }) + if err != nil { + return packit.BuildResult{}, err + } + + logger.Action("Completed in %s", duration.Round(time.Millisecond)) + logger.Break() + + logger.GeneratingSBOM(pixiLayer.Path) + + var sbomContent sbom.SBOM + duration, err = clock.Measure(func() error { + // Syft does not support pixi yet + sbomContent, err = sbomGenerator.Generate(pixiLayer.Path) + return err + }) + if err != nil { + return packit.BuildResult{}, err + } + logger.Action("Completed in %s", duration.Round(time.Millisecond)) + logger.Break() + + logger.FormattingSBOM(context.BuildpackInfo.SBOMFormats...) + + pixiLayer.SBOM, err = sbomContent.InFormats(context.BuildpackInfo.SBOMFormats...) + if err != nil { + return packit.BuildResult{}, err + } + + pixiLayer.Metadata = map[string]interface{}{ + "lockfile-sha": sha, + } + + pixiLayer.SharedEnv.Prepend("PATH", filepath.Join(pixiLayer.Path, PixiEnvironmentName, "bin"), string(os.PathListSeparator)) + + logger.EnvironmentVariables(pixiLayer) + + } else { + logger.Process("Reusing cached layer %s", pixiLayer.Path) + logger.Break() + } + + planner := draft.NewPlanner() + pixiLayer.Launch, pixiLayer.Build = planner.MergeLayerTypes(PixiEnvPlanEntry, context.Plan.Entries) + pixiLayer.Cache = pixiLayer.Build + pixiCacheLayer.Cache = true + + layers := []packit.Layer{pixiLayer} + if _, err := os.Stat(pixiCacheLayer.Path); err == nil { + if !fs.IsEmptyDir(pixiCacheLayer.Path) { + layers = append(layers, pixiCacheLayer) + } + } + + return packit.BuildResult{ + Layers: layers, + }, nil + } +} diff --git a/pkg/packagers/pixi/build_test.go b/pkg/packagers/pixi/build_test.go new file mode 100644 index 00000000..effe4e52 --- /dev/null +++ b/pkg/packagers/pixi/build_test.go @@ -0,0 +1,327 @@ +// SPDX-FileCopyrightText: © 2026 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package pixiinstall_test + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/paketo-buildpacks/packit/v2" + "github.com/paketo-buildpacks/packit/v2/chronos" + "github.com/paketo-buildpacks/packit/v2/sbom" + "github.com/paketo-buildpacks/packit/v2/scribe" + "github.com/sclevine/spec" + + pythonpackagers "github.com/paketo-buildpacks/python-packagers/pkg/packagers/common" + pixiinstall "github.com/paketo-buildpacks/python-packagers/pkg/packagers/pixi" + "github.com/paketo-buildpacks/python-packagers/pkg/packagers/pixi/fakes" + + . "github.com/onsi/gomega" +) + +func testBuild(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + + layersDir string + workingDir string + cnbDir string + + buffer *bytes.Buffer + + runner *fakes.Runner + sbomGenerator *fakes.SBOMGenerator + + build packit.BuildFunc + buildContext packit.BuildContext + ) + + it.Before(func() { + var err error + layersDir, err = os.MkdirTemp("", "layers") + Expect(err).NotTo(HaveOccurred()) + + cnbDir, err = os.MkdirTemp("", "cnb") + Expect(err).NotTo(HaveOccurred()) + + workingDir, err = os.MkdirTemp("", "working-dir") + Expect(err).NotTo(HaveOccurred()) + + runner = &fakes.Runner{} + sbomGenerator = &fakes.SBOMGenerator{} + + runner.ShouldRunCall.Returns.Bool = true + runner.ShouldRunCall.Returns.String = "some-sha" + + sbomGenerator.GenerateCall.Returns.SBOM = sbom.SBOM{} + + buffer = bytes.NewBuffer(nil) + logger := scribe.NewEmitter(buffer) + + build = pixiinstall.Build( + pixiinstall.PixiBuildParameters{ + runner, + }, + pythonpackagers.CommonBuildParameters{ + SbomGenerator: sbomGenerator, + Clock: chronos.DefaultClock, + Logger: logger, + }, + ) + buildContext = packit.BuildContext{ + BuildpackInfo: packit.BuildpackInfo{ + Name: "Some Buildpack", + Version: "some-version", + SBOMFormats: []string{sbom.CycloneDXFormat, sbom.SPDXFormat}, + }, + WorkingDir: workingDir, + CNBPath: cnbDir, + Plan: packit.BuildpackPlan{ + Entries: []packit.BuildpackPlanEntry{ + { + Name: pixiinstall.PixiEnvPlanEntry, + }, + }, + }, + Platform: packit.Platform{Path: "some-platform-path"}, + Layers: packit.Layers{Path: layersDir}, + Stack: "some-stack", + } + }) + + it.After(func() { + Expect(os.RemoveAll(layersDir)).To(Succeed()) + Expect(os.RemoveAll(cnbDir)).To(Succeed()) + Expect(os.RemoveAll(workingDir)).To(Succeed()) + }) + + it("returns a result that builds correctly", func() { + result, err := build(buildContext) + Expect(err).NotTo(HaveOccurred()) + + layers := result.Layers + Expect(layers).To(HaveLen(1)) + + pixiEnvLayer := layers[0] + Expect(pixiEnvLayer.Name).To(Equal("pixi-env")) + Expect(pixiEnvLayer.Path).To(Equal(filepath.Join(layersDir, "pixi-env"))) + + Expect(pixiEnvLayer.Build).To(BeFalse()) + Expect(pixiEnvLayer.Launch).To(BeFalse()) + Expect(pixiEnvLayer.Cache).To(BeFalse()) + + Expect(pixiEnvLayer.BuildEnv).To(BeEmpty()) + Expect(pixiEnvLayer.LaunchEnv).To(BeEmpty()) + Expect(pixiEnvLayer.ProcessLaunchEnv).To(BeEmpty()) + + Expect(pixiEnvLayer.SharedEnv).To(HaveLen(2)) + Expect(pixiEnvLayer.SharedEnv["PATH.prepend"]).To(Equal(filepath.Join(layersDir, "pixi-env", "default", "bin"))) + Expect(pixiEnvLayer.SharedEnv["PATH.delim"]).To(Equal(":")) + + Expect(pixiEnvLayer.Metadata).To(HaveLen(1)) + Expect(pixiEnvLayer.Metadata["lockfile-sha"]).To(Equal("some-sha")) + + Expect(pixiEnvLayer.SBOM.Formats()).To(HaveLen(2)) + var actualExtensions []string + for _, format := range pixiEnvLayer.SBOM.Formats() { + actualExtensions = append(actualExtensions, format.Extension) + } + Expect(actualExtensions).To(ConsistOf("cdx.json", "spdx.json")) + + Expect(runner.ExecuteCall.Receives.PixiEnvPath).To(Equal(filepath.Join(layersDir, "pixi-env"))) + Expect(runner.ExecuteCall.Receives.PixiCachePath).To(Equal(filepath.Join(layersDir, "pixi-env-cache"))) + Expect(runner.ExecuteCall.Receives.WorkingDir).To(Equal(workingDir)) + + Expect(sbomGenerator.GenerateCall.Receives.Dir).To(Equal(filepath.Join(layersDir, "pixi-env"))) + }) + + context("when the runner executes outputting a non-empty cache dir", func() { + it.Before(func() { + runner.ExecuteCall.Stub = func(_, c, _ string) error { + Expect(os.Mkdir(c, os.ModePerm)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(c, "some-file"), []byte{}, os.ModePerm)).To(Succeed()) + return nil + } + }) + + it.After(func() { + Expect(os.RemoveAll(filepath.Join(layersDir, "pixi-env-cache"))).To(Succeed()) + }) + + it("cache layer is exported", func() { + result, err := build(buildContext) + Expect(err).NotTo(HaveOccurred()) + + layers := result.Layers + Expect(layers).To(HaveLen(2)) + + pixiEnvLayer := layers[0] + Expect(pixiEnvLayer.Name).To(Equal("pixi-env")) + + cacheLayer := layers[1] + Expect(cacheLayer.Name).To(Equal("pixi-env-cache")) + Expect(cacheLayer.Path).To(Equal(filepath.Join(layersDir, "pixi-env-cache"))) + + Expect(cacheLayer.Build).To(BeFalse()) + Expect(cacheLayer.Launch).To(BeFalse()) + Expect(cacheLayer.Cache).To(BeTrue()) + }) + }) + + context("when a build plan entry requires pixi-environment at launch", func() { + it.Before(func() { + buildContext.Plan.Entries[0].Metadata = map[string]interface{}{ + "launch": true, + } + }) + + it("assigns the flag to the pixi env layer", func() { + result, err := build(buildContext) + Expect(err).NotTo(HaveOccurred()) + + layers := result.Layers + Expect(layers).To(HaveLen(1)) + + pixiEnvLayer := layers[0] + Expect(pixiEnvLayer.Name).To(Equal("pixi-env")) + + Expect(pixiEnvLayer.Build).To(BeFalse()) + Expect(pixiEnvLayer.Launch).To(BeTrue()) + Expect(pixiEnvLayer.Cache).To(BeFalse()) + }) + }) + + context("when a build plan entry requires pixi-environment at build", func() { + it.Before(func() { + buildContext.Plan.Entries[0].Metadata = map[string]interface{}{ + "build": true, + } + }) + + it("assigns build and cache to the pixi env layer", func() { + result, err := build(buildContext) + Expect(err).NotTo(HaveOccurred()) + + layers := result.Layers + Expect(layers).To(HaveLen(1)) + + pixiEnvLayer := layers[0] + Expect(pixiEnvLayer.Name).To(Equal("pixi-env")) + + Expect(pixiEnvLayer.Build).To(BeTrue()) + Expect(pixiEnvLayer.Launch).To(BeFalse()) + Expect(pixiEnvLayer.Cache).To(BeTrue()) + }) + }) + + context("cached packages should be reused", func() { + it.Before(func() { + runner.ShouldRunCall.Returns.Bool = false + runner.ShouldRunCall.Returns.String = "cached-sha" + }) + + it("reuses cached pixi env layer instead of running build process", func() { + result, err := build(buildContext) + Expect(err).NotTo(HaveOccurred()) + + layers := result.Layers + Expect(layers).To(HaveLen(1)) + + pixiEnvLayer := layers[0] + Expect(pixiEnvLayer.Name).To(Equal("pixi-env")) + + Expect(runner.ExecuteCall.CallCount).To(BeZero()) + }) + }) + + context("failure cases", func() { + context("pixi layer cannot be fetched", func() { + it.Before(func() { + Expect(os.WriteFile(filepath.Join(layersDir, "pixi-env.toml"), nil, 0000)).To(Succeed()) + }) + + it("returns an error", func() { + _, err := build(buildContext) + Expect(err).To(MatchError(ContainSubstring("permission denied"))) + }) + }) + + context("pixi cache layer cannot be fetched", func() { + it.Before(func() { + Expect(os.WriteFile(filepath.Join(layersDir, "pixi-env-cache.toml"), nil, 0000)).To(Succeed()) + }) + + it("returns an error", func() { + _, err := build(buildContext) + Expect(err).To(MatchError(ContainSubstring("permission denied"))) + }) + }) + + context("runner ShouldRun fails", func() { + it.Before(func() { + runner.ShouldRunCall.Returns.Error = errors.New("some-shouldrun-error") + }) + + it("returns an error", func() { + _, err := build(buildContext) + Expect(err).To(MatchError("some-shouldrun-error")) + }) + }) + + context("layer cannot be reset", func() { + it.Before(func() { + runner.ShouldRunCall.Returns.Bool = true + Expect(os.Chmod(layersDir, 0500)).To(Succeed()) + }) + + it.After(func() { + Expect(os.Chmod(layersDir, os.ModePerm)).To(Succeed()) + }) + + it("returns an error", func() { + _, err := build(buildContext) + Expect(err).To(MatchError(ContainSubstring("error could not create directory"))) + }) + }) + + context("install process fails to execute", func() { + it.Before(func() { + runner.ShouldRunCall.Returns.Bool = true + runner.ExecuteCall.Returns.Error = errors.New("some execution error") + }) + + it("returns an error", func() { + _, err := build(buildContext) + Expect(err).To(MatchError(ContainSubstring("some execution error"))) + }) + }) + + context("when generating the SBOM returns an error", func() { + it.Before(func() { + buildContext.BuildpackInfo.SBOMFormats = []string{"random-format"} + }) + + it("returns an error", func() { + _, err := build(buildContext) + Expect(err).To(MatchError(`unsupported SBOM format: 'random-format'`)) + }) + }) + + context("when formatting the SBOM returns an error", func() { + it.Before(func() { + sbomGenerator.GenerateCall.Returns.Error = errors.New("failed to generate SBOM") + }) + + it("returns an error", func() { + _, err := build(buildContext) + Expect(err).To(MatchError(ContainSubstring("failed to generate SBOM"))) + }) + }) + }) +} diff --git a/pkg/packagers/pixi/constants.go b/pkg/packagers/pixi/constants.go new file mode 100644 index 00000000..fd78ca97 --- /dev/null +++ b/pkg/packagers/pixi/constants.go @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: © 2026 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package pixiinstall + +const ( + // PixiEnvLayer is the name of the layer into which pixi environment is installed. + PixiEnvLayer = "pixi-env" + + // PixiEnvCache is the name of the layer that is used as the pixi package directory. + PixiEnvCache = "pixi-env-cache" + + // PixiEnvPlanEntry is the name of the Build Plan requirement that this buildpack provides. + PixiEnvPlanEntry = "pixi-environment" + + // PixiPlanEntry is the name of the Build Plan requirement for the minipixi + // dependency that this buildpack requires. + PixiPlanEntry = "pixi" + + // LockfileShaName is the key in the Layer Content Metadata used to determine if layer + // can be reused. + LockfileShaName = "lockfile-sha" + + // LockfileName is the name of the export file from which the buildpack reinstalls packages + // See https://docs.pixi.io/projects/pixi/en/latest/commands/list.html + LockfileName = "pixi.lock" + + // ProjectFilename is the name of the pixi environment file. + ProjectFilename = "pixi.toml" + + // PixiEnvironmentName is the name of environment created out of the project + PixiEnvironmentName = "default" +) diff --git a/pkg/packagers/pixi/detect.go b/pkg/packagers/pixi/detect.go new file mode 100644 index 00000000..8fc2591a --- /dev/null +++ b/pkg/packagers/pixi/detect.go @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: © 2026 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package pixiinstall + +import ( + "path/filepath" + + "github.com/paketo-buildpacks/packit/v2" + "github.com/paketo-buildpacks/packit/v2/fs" + + common "github.com/paketo-buildpacks/python-packagers/pkg/packagers/common" +) + +// Detect returns a packit.DetectFunc that will be invoked during the +// detect phase of the buildpack lifecycle. +// +// Detection passes when there is an environment.yml or package-list.txt file +// in the app directory, and will contribute a Build Plan that provides +// pixi-environment and requires pixi. +func Detect() packit.DetectFunc { + return func(context packit.DetectContext) (packit.DetectResult, error) { + projectFile, err := fs.Exists(filepath.Join(context.WorkingDir, ProjectFilename)) + if err != nil { + return packit.DetectResult{}, packit.Fail.WithMessage("failed trying to stat %s: %w", ProjectFilename, err) + } + lockFile, err := fs.Exists(filepath.Join(context.WorkingDir, LockfileName)) + if err != nil { + return packit.DetectResult{}, packit.Fail.WithMessage("failed trying to stat %s: %w", LockfileName, err) + } + + if !projectFile && !lockFile { + return packit.DetectResult{}, packit.Fail.WithMessage("no '%s' and '%s' found", ProjectFilename, LockfileName) + } + + requires := []packit.BuildPlanRequirement{ + { + Name: PixiPlanEntry, + Metadata: common.BuildPlanMetadata{ + Build: true, + }, + }, + } + + return packit.DetectResult{ + Plan: packit.BuildPlan{ + Provides: []packit.BuildPlanProvision{ + {Name: PixiEnvPlanEntry}, + }, + Requires: requires, + }, + }, nil + } +} diff --git a/pkg/packagers/pixi/detect_test.go b/pkg/packagers/pixi/detect_test.go new file mode 100644 index 00000000..e62427ae --- /dev/null +++ b/pkg/packagers/pixi/detect_test.go @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: © 2026 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package pixiinstall_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/paketo-buildpacks/packit/v2" + "github.com/sclevine/spec" + + . "github.com/onsi/gomega" + + common "github.com/paketo-buildpacks/python-packagers/pkg/packagers/common" + pixiinstall "github.com/paketo-buildpacks/python-packagers/pkg/packagers/pixi" +) + +func testDetect(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + + workingDir string + detect packit.DetectFunc + ) + + it.Before(func() { + var err error + workingDir, err = os.MkdirTemp("", "working-dir") + Expect(err).NotTo(HaveOccurred()) + + detect = pixiinstall.Detect() + }) + + it.After(func() { + Expect(os.RemoveAll(workingDir)).To(Succeed()) + }) + + context("when there is an pixi.lock in the working dir", func() { + it.Before(func() { + Expect(os.WriteFile(filepath.Join(workingDir, pixiinstall.LockfileName), nil, 0644)).To(Succeed()) + }) + + it("detects", func() { + result, err := detect(packit.DetectContext{ + WorkingDir: workingDir, + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(result.Plan).To(Equal(packit.BuildPlan{ + Provides: []packit.BuildPlanProvision{ + { + Name: "pixi-environment", + }, + }, + Requires: []packit.BuildPlanRequirement{ + { + Name: "pixi", + Metadata: common.BuildPlanMetadata{ + Build: true, + }, + }, + }, + })) + }) + }) + + context("when there is an pixi.toml in the working dir", func() { + it.Before(func() { + Expect(os.WriteFile(filepath.Join(workingDir, pixiinstall.ProjectFilename), nil, 0644)).To(Succeed()) + }) + + it("detects", func() { + result, err := detect(packit.DetectContext{ + WorkingDir: workingDir, + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(result.Plan).To(Equal(packit.BuildPlan{ + Provides: []packit.BuildPlanProvision{ + { + Name: "pixi-environment", + }, + }, + Requires: []packit.BuildPlanRequirement{ + { + Name: "pixi", + Metadata: common.BuildPlanMetadata{ + Build: true, + }, + }, + }, + })) + }) + }) + + context("when no pixi.toml or pixi.lock is present in the working dir", func() { + it("fails to detect", func() { + _, err := detect(packit.DetectContext{ + WorkingDir: workingDir, + }) + Expect(err).To(MatchError(packit.Fail.WithMessage("no 'pixi.toml' and 'pixi.lock' found"))) + }) + }) + + context("failure cases", func() { + context("when the file cannot be stat'd", func() { + it.Before(func() { + Expect(os.Chmod(workingDir, 0000)).To(Succeed()) + }) + + it.After(func() { + Expect(os.Chmod(workingDir, os.ModePerm)).To(Succeed()) + }) + + it("returns an error", func() { + _, err := detect(packit.DetectContext{ + WorkingDir: workingDir, + }) + Expect(err).To(MatchError(ContainSubstring("failed trying to stat pixi.toml:"))) + Expect(err).To(MatchError(ContainSubstring("permission denied"))) + }) + }) + }) +} diff --git a/pkg/packagers/pixi/fakes/executable.go b/pkg/packagers/pixi/fakes/executable.go new file mode 100644 index 00000000..ea57e1a4 --- /dev/null +++ b/pkg/packagers/pixi/fakes/executable.go @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: © 2026 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package fakes + +import ( + "sync" + + "github.com/paketo-buildpacks/packit/v2/pexec" +) + +type Executable struct { + ExecuteCall struct { + mutex sync.Mutex + CallCount int + Receives struct { + Execution pexec.Execution + } + Returns struct { + Error error + } + Stub func(pexec.Execution) error + } +} + +func (f *Executable) Execute(param1 pexec.Execution) error { + f.ExecuteCall.mutex.Lock() + defer f.ExecuteCall.mutex.Unlock() + f.ExecuteCall.CallCount++ + f.ExecuteCall.Receives.Execution = param1 + if f.ExecuteCall.Stub != nil { + return f.ExecuteCall.Stub(param1) + } + return f.ExecuteCall.Returns.Error +} diff --git a/pkg/packagers/pixi/fakes/runner.go b/pkg/packagers/pixi/fakes/runner.go new file mode 100644 index 00000000..d09ad798 --- /dev/null +++ b/pkg/packagers/pixi/fakes/runner.go @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: © 2026 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package fakes + +import "sync" + +type Runner struct { + ExecuteCall struct { + mutex sync.Mutex + CallCount int + Receives struct { + PixiEnvPath string + PixiCachePath string + WorkingDir string + } + Returns struct { + Error error + } + Stub func(string, string, string) error + } + ShouldRunCall struct { + mutex sync.Mutex + CallCount int + Receives struct { + WorkingDir string + Metadata map[string]interface { + } + } + Returns struct { + Bool bool + String string + Error error + } + Stub func(string, map[string]interface { + }) (bool, string, error) + } +} + +func (f *Runner) Execute(param1 string, param2 string, param3 string) error { + f.ExecuteCall.mutex.Lock() + defer f.ExecuteCall.mutex.Unlock() + f.ExecuteCall.CallCount++ + f.ExecuteCall.Receives.PixiEnvPath = param1 + f.ExecuteCall.Receives.PixiCachePath = param2 + f.ExecuteCall.Receives.WorkingDir = param3 + if f.ExecuteCall.Stub != nil { + return f.ExecuteCall.Stub(param1, param2, param3) + } + return f.ExecuteCall.Returns.Error +} +func (f *Runner) ShouldRun(param1 string, param2 map[string]interface { +}) (bool, string, error) { + f.ShouldRunCall.mutex.Lock() + defer f.ShouldRunCall.mutex.Unlock() + f.ShouldRunCall.CallCount++ + f.ShouldRunCall.Receives.WorkingDir = param1 + f.ShouldRunCall.Receives.Metadata = param2 + if f.ShouldRunCall.Stub != nil { + return f.ShouldRunCall.Stub(param1, param2) + } + return f.ShouldRunCall.Returns.Bool, f.ShouldRunCall.Returns.String, f.ShouldRunCall.Returns.Error +} diff --git a/pkg/packagers/pixi/fakes/sbom_generator.go b/pkg/packagers/pixi/fakes/sbom_generator.go new file mode 100644 index 00000000..8e8bef4b --- /dev/null +++ b/pkg/packagers/pixi/fakes/sbom_generator.go @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: © 2026 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package fakes + +import ( + "sync" + + "github.com/paketo-buildpacks/packit/v2/sbom" +) + +type SBOMGenerator struct { + GenerateCall struct { + mutex sync.Mutex + CallCount int + Receives struct { + Dir string + } + Returns struct { + SBOM sbom.SBOM + Error error + } + Stub func(string) (sbom.SBOM, error) + } +} + +func (f *SBOMGenerator) Generate(param1 string) (sbom.SBOM, error) { + f.GenerateCall.mutex.Lock() + defer f.GenerateCall.mutex.Unlock() + f.GenerateCall.CallCount++ + f.GenerateCall.Receives.Dir = param1 + if f.GenerateCall.Stub != nil { + return f.GenerateCall.Stub(param1) + } + return f.GenerateCall.Returns.SBOM, f.GenerateCall.Returns.Error +} diff --git a/pkg/packagers/pixi/fakes/summer.go b/pkg/packagers/pixi/fakes/summer.go new file mode 100644 index 00000000..0ae006ee --- /dev/null +++ b/pkg/packagers/pixi/fakes/summer.go @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: © 2026 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package fakes + +import "sync" + +type Summer struct { + SumCall struct { + mutex sync.Mutex + CallCount int + Receives struct { + Arg []string + } + Returns struct { + String string + Error error + } + Stub func(...string) (string, error) + } +} + +func (f *Summer) Sum(param1 ...string) (string, error) { + f.SumCall.mutex.Lock() + defer f.SumCall.mutex.Unlock() + f.SumCall.CallCount++ + f.SumCall.Receives.Arg = param1 + if f.SumCall.Stub != nil { + return f.SumCall.Stub(param1...) + } + return f.SumCall.Returns.String, f.SumCall.Returns.Error +} diff --git a/pkg/packagers/pixi/init_test.go b/pkg/packagers/pixi/init_test.go new file mode 100644 index 00000000..c57328dc --- /dev/null +++ b/pkg/packagers/pixi/init_test.go @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: © 2026 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package pixiinstall_test + +import ( + "testing" + + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" +) + +func TestUnitPixiInstall(t *testing.T) { + suite := spec.New("pixiinstall", spec.Report(report.Terminal{}), spec.Parallel()) + suite("Build", testBuild) + suite("PixiRunner", testPixiRunner, spec.Sequential()) + suite("Detect", testDetect) + suite.Run(t) +} diff --git a/pkg/packagers/pixi/pixi_runner.go b/pkg/packagers/pixi/pixi_runner.go new file mode 100644 index 00000000..fc42ea91 --- /dev/null +++ b/pkg/packagers/pixi/pixi_runner.go @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: © 2026 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package pixiinstall + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/paketo-buildpacks/packit/v2/fs" + "github.com/paketo-buildpacks/packit/v2/pexec" + "github.com/paketo-buildpacks/packit/v2/scribe" +) + +//go:generate faux --interface Executable --output fakes/executable.go + +// Executable defines the interface for invoking an executable. +type Executable interface { + Execute(pexec.Execution) error +} + +// Summer defines the interface for computing a SHA256 for a set of files +// and/or directories. +// +//go:generate faux --interface Summer --output fakes/summer.go +type Summer interface { + Sum(arg ...string) (string, error) +} + +// PixiRunner implements the Runner interface. +type PixiRunner struct { + logger scribe.Emitter + executable Executable + summer Summer +} + +type Package struct { + Name string `json:"name"` + Version string `json:"version"` + Build string `json:"build"` +} + +// NewPixiRunner creates an instance of PixiRunner given an Executable, a Summer, and a Logger. +func NewPixiRunner(executable Executable, summer Summer, logger scribe.Emitter) PixiRunner { + return PixiRunner{ + executable: executable, + summer: summer, + logger: logger, + } +} + +// ShouldRun determines whether the pixi environment setup command needs to be +// run, given the path to the app directory and the metadata from the +// preexisting pixi-env layer. It returns true if the pixi environment setup +// command must be run during this build, the SHA256 of the package-list.txt in +// the app directory, and an error. If there is no package-list.txt, the sha +// returned is an empty string. +func (c PixiRunner) ShouldRun(workingDir string, metadata map[string]interface{}) (run bool, sha string, err error) { + lockfilePath := filepath.Join(workingDir, LockfileName) + _, err = os.Stat(lockfilePath) + + if errors.Is(err, os.ErrNotExist) { + return true, "", nil + } + + if err != nil { + return false, "", err + } + + updatedLockfileSha, err := c.summer.Sum(lockfilePath) + if err != nil { + return false, "", err + } + + if updatedLockfileSha == metadata[LockfileShaName] { + return false, updatedLockfileSha, nil + } + + return true, updatedLockfileSha, nil +} + +// Do the actual command execution +func (c PixiRunner) run(args []string) error { + c.logger.Subprocess("Running 'pixi %s'", strings.Join(args, " ")) + + err := c.executable.Execute(pexec.Execution{ + Args: args, + Stdout: c.logger.ActionWriter, + Stderr: c.logger.ActionWriter, + }) + + if err != nil { + return fmt.Errorf("failed to run pixi command: %w", err) + } + + return err +} + +// Execute runs the pixi pack and unpack command to create a usable +// environment. +// +// For more information about the commands used, see: +// https://pixi.prefix.dev/latest/deployment/pixi_pack/ +func (c PixiRunner) Execute(pixiLayerPath string, pixiCachePath string, workingDir string) error { + lockfileExists, err := fs.Exists(filepath.Join(workingDir, LockfileName)) + if err != nil { + return err + } + projectFileExists, err := fs.Exists(filepath.Join(workingDir, ProjectFilename)) + if err != nil { + return err + } + + if !lockfileExists && !projectFileExists { + return fmt.Errorf("missing both %s and %s", LockfileName, ProjectFilename) + } + + args := []string{ + "exec", + "pixi-pack", + "--use-cache", pixiCachePath, + "--output-file", "/tmp/project.tar.gz", + workingDir, + } + + err = c.run(args) + + if err != nil { + return err + } + + args = []string{ + "exec", + "pixi-unpack", + "--output-directory", pixiLayerPath, + "--env-name", PixiEnvironmentName, + "/tmp/project.tar.gz", + } + + return c.run(args) +} diff --git a/pkg/packagers/pixi/pixi_runner_test.go b/pkg/packagers/pixi/pixi_runner_test.go new file mode 100644 index 00000000..9b09ad6e --- /dev/null +++ b/pkg/packagers/pixi/pixi_runner_test.go @@ -0,0 +1,201 @@ +// SPDX-FileCopyrightText: © 2026 Idiap Research Institute +// SPDX-FileContributor: Samuel Gaist +// +// SPDX-License-Identifier: Apache-2.0 + +package pixiinstall_test + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/paketo-buildpacks/packit/v2/pexec" + "github.com/paketo-buildpacks/packit/v2/scribe" + "github.com/sclevine/spec" + + . "github.com/onsi/gomega" + . "github.com/paketo-buildpacks/occam/matchers" + + pixiinstall "github.com/paketo-buildpacks/python-packagers/pkg/packagers/pixi" + "github.com/paketo-buildpacks/python-packagers/pkg/packagers/pixi/fakes" +) + +func testPixiRunner(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + + workingDir string + pixiLayerPath string + pixiCachePath string + + executable *fakes.Executable + executions []pexec.Execution + summer *fakes.Summer + runner pixiinstall.PixiRunner + buffer *bytes.Buffer + logger scribe.Emitter + ) + + it.Before(func() { + workingDir = t.TempDir() + layersDir := t.TempDir() + + pixiLayerPath = filepath.Join(layersDir, "a-pixi-layer") + pixiCachePath = filepath.Join(layersDir, "a-pixi-cache-path") + + executable = &fakes.Executable{} + executions = []pexec.Execution{} + executable.ExecuteCall.Stub = func(ex pexec.Execution) error { + executions = append(executions, ex) + Expect(os.MkdirAll(filepath.Join(pixiLayerPath, "pixi-meta"), os.ModePerm)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(pixiLayerPath, "pixi-meta", "history"), []byte("some content"), os.ModePerm)).To(Succeed()) + _, err := fmt.Fprintln(ex.Stdout, "stdout output") + Expect(err).NotTo(HaveOccurred()) + _, err = fmt.Fprintln(ex.Stderr, "stderr output") + Expect(err).NotTo(HaveOccurred()) + return nil + } + + summer = &fakes.Summer{} + buffer = bytes.NewBuffer(nil) + logger = scribe.NewEmitter(buffer) + runner = pixiinstall.NewPixiRunner(executable, summer, logger) + }) + + context("ShouldRun", func() { + it("returns true, with no sha, and no error when no lockfile is present", func() { + run, sha, err := runner.ShouldRun(workingDir, map[string]interface{}{}) + Expect(run).To(BeTrue()) + Expect(sha).To(Equal("")) + Expect(err).NotTo(HaveOccurred()) + }) + + context("when there is an error checking if a lockfile is present", func() { + it.Before(func() { + Expect(os.Chmod(workingDir, 0000)).To(Succeed()) + }) + + it.After(func() { + Expect(os.Chmod(workingDir, os.ModePerm)).To(Succeed()) + }) + + it("returns false, with no sha, and an error", func() { + run, sha, err := runner.ShouldRun(workingDir, map[string]interface{}{}) + Expect(run).To(BeFalse()) + Expect(sha).To(Equal("")) + Expect(err).To(HaveOccurred()) + }) + }) + + context("when a lockfile is present", func() { + it.Before(func() { + Expect(os.WriteFile(filepath.Join(workingDir, pixiinstall.LockfileName), nil, os.ModePerm)).To(Succeed()) + }) + context("and the lockfile sha is unchanged", func() { + it("return false, with the existing sha, and no error", func() { + summer.SumCall.Returns.String = "a-sha" + Expect(os.WriteFile(filepath.Join(workingDir, pixiinstall.LockfileName), nil, os.ModePerm)).To(Succeed()) + + metadata := map[string]interface{}{ + "lockfile-sha": "a-sha", + } + + run, sha, err := runner.ShouldRun(workingDir, metadata) + Expect(run).To(BeFalse()) + Expect(sha).To(Equal("a-sha")) + Expect(err).NotTo(HaveOccurred()) + }) + context("and there is and error summing the lock file", func() { + it.Before(func() { + summer.SumCall.Returns.Error = errors.New("summing lockfile failed") + }) + + it("returns false, with no sha, and an error", func() { + run, sha, err := runner.ShouldRun(workingDir, map[string]interface{}{}) + Expect(run).To(BeFalse()) + Expect(sha).To(Equal("")) + Expect(err).To(MatchError("summing lockfile failed")) + + }) + }) + }) + + it("returns true, with a new sha, and no error when the lockfile has changed", func() { + summer.SumCall.Returns.String = "a-new-sha" + metadata := map[string]interface{}{ + "lockfile-sha": "a-sha", + } + + run, sha, err := runner.ShouldRun(workingDir, metadata) + Expect(run).To(BeTrue()) + Expect(sha).To(Equal("a-new-sha")) + Expect(err).NotTo(HaveOccurred()) + }) + }) + }) + context("Execute", func() { + context("when a lockfile exists", func() { + it.Before(func() { + Expect(os.WriteFile(filepath.Join(workingDir, pixiinstall.LockfileName), nil, os.ModePerm)).To(Succeed()) + }) + + it("runs pixi create with the cache layer available in the environment", func() { + err := runner.Execute(pixiLayerPath, pixiCachePath, workingDir) + Expect(err).NotTo(HaveOccurred()) + + Expect(executions[0].Args).To(Equal([]string{ + "exec", + "pixi-pack", + "--use-cache", pixiCachePath, + "--output-file", "/tmp/project.tar.gz", + workingDir, + })) + Expect(executable.ExecuteCall.CallCount).To(Equal(2)) + Expect(executions[1].Args).To(Equal([]string{ + "exec", + "pixi-unpack", + "--output-directory", pixiLayerPath, + "--env-name", pixiinstall.PixiEnvironmentName, + "/tmp/project.tar.gz", + })) + }) + }) + + context("failure cases", func() { + it.Before(func() { + Expect(os.WriteFile(filepath.Join(workingDir, pixiinstall.LockfileName), nil, os.ModePerm)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(workingDir, pixiinstall.ProjectFilename), nil, os.ModePerm)).To(Succeed()) + }) + context("when the pixi exec command fails to run", func() { + it.Before(func() { + executable.ExecuteCall.Stub = func(ex pexec.Execution) error { + _, err := fmt.Fprintln(ex.Stdout, "pixi error stdout") + Expect(err).NotTo(HaveOccurred()) + _, err = fmt.Fprintln(ex.Stderr, "pixi error stderr") + Expect(err).NotTo(HaveOccurred()) + return errors.New("some pixi failure") + } + }) + + it("returns an error and logs the stdout and stderr output from the command", func() { + err := runner.Execute(pixiLayerPath, pixiCachePath, workingDir) + Expect(err).To(MatchError("failed to run pixi command: some pixi failure")) + Expect(buffer.String()).To(ContainLines( + fmt.Sprintf( + " Running 'pixi exec pixi-pack --use-cache %s --output-file /tmp/project.tar.gz %s'", + pixiCachePath, + workingDir, + ), + " pixi error stdout", + " pixi error stderr", + )) + }) + }) + + }) + }) +} diff --git a/run/main.go b/run/main.go index 75c49050..edae6cb6 100644 --- a/run/main.go +++ b/run/main.go @@ -19,6 +19,7 @@ import ( conda "github.com/paketo-buildpacks/python-packagers/pkg/packagers/conda" pipinstall "github.com/paketo-buildpacks/python-packagers/pkg/packagers/pip" pipenvinstall "github.com/paketo-buildpacks/python-packagers/pkg/packagers/pipenv" + pixiinstall "github.com/paketo-buildpacks/python-packagers/pkg/packagers/pixi" poetryinstall "github.com/paketo-buildpacks/python-packagers/pkg/packagers/poetry" uvinstall "github.com/paketo-buildpacks/python-packagers/pkg/packagers/uv" ) @@ -53,6 +54,9 @@ func main() { uvinstall.UvEnvPlanEntry: uvinstall.UvBuildParameters{ Runner: uvinstall.NewUvRunner(pexec.NewExecutable("uv"), fs.NewChecksumCalculator(), logger), }, + pixiinstall.PixiEnvPlanEntry: pixiinstall.PixiBuildParameters{ + Runner: pixiinstall.NewPixiRunner(pexec.NewExecutable("pixi"), fs.NewChecksumCalculator(), logger), + }, } packit.Run(