feat: use py package versions from any mise-supported version specification file (#261)

* test: add .tool-versions test

* feat: extract locally defined mise package versions

* test: .tool-versions unit tests

* docs: integration tests docs

* build: local mise config for less annoying warnings

* feat: pull python ecosystem versions from mise

* feat: internal support for extracting mise-specified package versions from app folder

* refactor: move mise package version methods into context

* test: snapshots

* test: multiple test.json expectation lines

this is using newly implemented multiple expectations syntax

* test: support multiple expectations per test.json entry, don't use additional non-deterministic contains to throw an error

* refactor: refine mise interface

* refactor: make mise versions easier for a provider to use

* test: snapshot update

* refactor: move mise local package logic to step builder

* test: move mise package version extraction to mise step builder tests

* refactor: move mise package into mise step builder
This commit is contained in:
Michael Bianco
2025-10-01 07:14:27 -06:00
committed by GitHub
parent 865def5e53
commit 4cf15bc783
15 changed files with 482 additions and 13 deletions
@@ -0,0 +1,154 @@
{
"caches": {
"uv": {
"directory": "/opt/uv-cache",
"type": "shared"
}
},
"deploy": {
"base": {
"image": "ghcr.io/railwayapp/railpack-runtime:latest"
},
"inputs": [
{
"include": [
"/mise/shims",
"/mise/installs",
"/usr/local/bin/mise",
"/etc/mise/config.toml",
"/root/.local/state/mise",
".tool-versions"
],
"step": "packages:mise"
},
{
"include": [
"/app/.venv"
],
"step": "build"
},
{
"exclude": [
".venv"
],
"include": [
"."
],
"step": "build"
}
],
"startCommand": "python main.py",
"variables": {
"PIP_DEFAULT_TIMEOUT": "100",
"PIP_DISABLE_PIP_VERSION_CHECK": "1",
"PYTHONDONTWRITEBYTECODE": "1",
"PYTHONFAULTHANDLER": "1",
"PYTHONHASHSEED": "random",
"PYTHONUNBUFFERED": "1"
}
},
"steps": [
{
"assets": {
"mise.toml": "[mise.toml]"
},
"commands": [
{
"path": "/mise/shims"
},
{
"dest": ".tool-versions",
"src": ".tool-versions"
},
{
"customName": "create mise config",
"name": "mise.toml",
"path": "/etc/mise/config.toml"
},
{
"cmd": "sh -c 'mise trust -a \u0026\u0026 mise install'",
"customName": "install mise packages: pipx, pipx:uv, python"
}
],
"inputs": [
{
"image": "ghcr.io/railwayapp/railpack-builder:latest"
}
],
"name": "packages:mise",
"variables": {
"MISE_CACHE_DIR": "/mise/cache",
"MISE_CONFIG_DIR": "/mise",
"MISE_DATA_DIR": "/mise",
"MISE_INSTALLS_DIR": "/mise/installs",
"MISE_NODE_VERIFY": "false",
"MISE_SHIMS_DIR": "/mise/shims"
}
},
{
"caches": [
"uv"
],
"commands": [
{
"dest": "pyproject.toml",
"src": "pyproject.toml"
},
{
"dest": "uv.lock",
"src": "uv.lock"
},
{
"path": "/root/.local/bin"
},
{
"path": "/app/.venv/bin"
},
{
"cmd": "uv sync --locked --no-dev --no-install-project"
}
],
"inputs": [
{
"step": "packages:mise"
}
],
"name": "install",
"variables": {
"PIP_DEFAULT_TIMEOUT": "100",
"PIP_DISABLE_PIP_VERSION_CHECK": "1",
"PYTHONDONTWRITEBYTECODE": "1",
"PYTHONFAULTHANDLER": "1",
"PYTHONHASHSEED": "random",
"PYTHONUNBUFFERED": "1",
"UV_CACHE_DIR": "/opt/uv-cache",
"UV_COMPILE_BYTECODE": "1",
"UV_LINK_MODE": "copy",
"UV_PYTHON_DOWNLOADS": "never",
"VIRTUAL_ENV": "/app/.venv"
}
},
{
"commands": [
{
"cmd": "uv sync --locked --no-dev --no-editable"
}
],
"inputs": [
{
"step": "install"
},
{
"include": [
"."
],
"local": true
}
],
"name": "build",
"secrets": [
"*"
]
}
]
}
+11
View File
@@ -262,3 +262,14 @@ func (c *GenerateContext) NewLocalLayer() plan.Layer {
return layer
}
// in order to get around a circular dependency issue, we need to define discrete getters to interface with
// the mise package version logic.
func (c *GenerateContext) GetAppSource() string {
return c.App.Source
}
func (c *GenerateContext) GetLogger() *logger.Logger {
return c.Logger
}
+107
View File
@@ -1,6 +1,8 @@
// build step that installs packages defined by mise configuration, provider configuration, or user configuration
package generate
import (
"encoding/json"
"fmt"
"maps"
"sort"
@@ -17,6 +19,32 @@ const (
MiseInstallCommand = "sh -c 'mise trust -a && mise install'"
)
// represents a app-local mise package
type MisePackageInfo struct {
Version string
Source string
}
// MiseListSource represents the source of a mise tool installation
type MiseListSource struct {
Type string `json:"type"`
Path string `json:"path"`
}
// MiseListTool represents a tool in the mise list output
type MiseListTool struct {
Version string `json:"version"`
RequestedVersion string `json:"requested_version"`
InstallPath string `json:"install_path"`
Source MiseListSource `json:"source"`
Installed bool `json:"installed"`
// --current ensures Active=true for all entries
Active bool `json:"active"`
}
// MisePackageListOutput represents the full output of `mise list --current --json`
type MisePackageListOutput map[string][]MiseListTool
type MiseStepBuilder struct {
DisplayName string
Resolver *resolver.Resolver
@@ -84,6 +112,85 @@ func (b *MiseStepBuilder) SkipMiseInstall(name resolver.PackageRef) {
b.Resolver.SetSkipMiseInstall(name, true)
}
// GetMisePackageVersions gets all package versions from mise that are defined in the app directory environment
// this can include additional packages defined outside the app directory, but we filter those out
func (b *MiseStepBuilder) GetMisePackageVersions(ctx *GenerateContext) (map[string]*MisePackageInfo, error) {
miseInstance, err := mise.New(mise.InstallDir)
if err != nil {
return nil, err
}
appDir := ctx.GetAppSource()
output, err := miseInstance.GetCurrentList(appDir)
if err != nil {
return nil, fmt.Errorf("failed to get package versions: %w", err)
}
var listOutput MisePackageListOutput
if err := json.Unmarshal([]byte(output), &listOutput); err != nil {
return nil, fmt.Errorf("failed to parse mise list output: %w", err)
}
packages := make(map[string]*MisePackageInfo)
for toolName, tools := range listOutput {
var appDirTools []MiseListTool
for _, tool := range tools {
// Only include tools that are sourced from within the app directory
if strings.HasPrefix(tool.Source.Path, appDir) {
appDirTools = append(appDirTools, tool)
}
}
if len(appDirTools) > 1 {
versions := make([]string, len(appDirTools))
for i, tool := range appDirTools {
versions[i] = tool.Version
}
// this is possible, although in practice it should be extremely rare
ctx.GetLogger().LogWarn("Multiple versions of tool '%s' found: %v. Using the first one: %s",
toolName, versions, versions[0])
}
if len(appDirTools) > 0 {
firstTool := appDirTools[0]
packages[toolName] = &MisePackageInfo{
Version: firstTool.Version,
// include the source so we can surface this to the user so they understand where the package version came from
Source: firstTool.Source.Type,
}
}
}
return packages, nil
}
// Use mise-specified versions for all packages in the input list
func (b *MiseStepBuilder) UseMiseVersions(ctx *GenerateContext, packages []string) {
miseVersions, err := b.GetMisePackageVersions(ctx)
if err != nil {
ctx.Logger.LogWarn("Failed to get package versions from mise: %s", err.Error())
return
}
if miseVersions == nil {
return
}
for _, packageName := range packages {
if pkg := miseVersions[packageName]; pkg != nil {
// Find the existing package reference
for _, pkgRef := range b.MisePackages {
if pkgRef.Name == packageName {
b.Version(*pkgRef, pkg.Version, pkg.Source)
break
}
}
}
}
}
func (b *MiseStepBuilder) Name() string {
return b.DisplayName
}
+90
View File
@@ -0,0 +1,90 @@
package generate
import (
"os"
"strings"
"testing"
"github.com/railwayapp/railpack/core/resolver"
"github.com/stretchr/testify/require"
)
func TestGetPackageVersions(t *testing.T) {
tempDir, err := os.MkdirTemp("", "mise-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ctx := CreateTestContext(t, "../../examples/python-uv-tool-versions")
// Create a resolver
resolver, err := resolver.NewResolver(tempDir)
require.NoError(t, err)
builder := &MiseStepBuilder{
Resolver: resolver,
app: ctx.App,
env: ctx.Env,
}
packages, err := builder.GetMisePackageVersions(ctx)
require.NoError(t, err)
// Expected packages from the example
expected := map[string]struct{}{
"python": {},
"uv": {},
}
// Ensure ONLY expected packages are present
require.Len(t, packages, len(expected), "unexpected number of packages returned")
for name := range packages {
_, ok := expected[name]
require.True(t, ok, "unexpected package found: %s", name)
}
// The python-uv-tool-versions example should have python and uv defined
require.Contains(t, packages, "python")
require.Contains(t, packages, "uv")
// Verify versions are not empty
require.NotEmpty(t, packages["python"].Version)
require.NotEmpty(t, packages["uv"].Version)
// Verify python version starts with "3.9" (as defined in .tool-versions)
require.True(t, strings.HasPrefix(packages["python"].Version, "3.9"))
// Verify uv version starts with "0.7" (as defined in .tool-versions)
require.True(t, strings.HasPrefix(packages["uv"].Version, "0.7"))
// Verify source types are set
require.NotEmpty(t, packages["python"].Source)
require.NotEmpty(t, packages["uv"].Source)
}
func TestGetPackageVersionsWithNoToolVersions(t *testing.T) {
tempDir, err := os.MkdirTemp("", "mise-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ctx := CreateTestContext(t, "../../examples/node-tanstack-start")
// Create a resolver
resolver, err := resolver.NewResolver(tempDir)
require.NoError(t, err)
builder := &MiseStepBuilder{
Resolver: resolver,
app: ctx.App,
env: ctx.Env,
}
packages, err := builder.GetMisePackageVersions(ctx)
require.NoError(t, err)
// Should return empty map for directory with no .tool-versions
require.Empty(t, packages)
}
+8 -2
View File
@@ -1,3 +1,4 @@
// helper utilities to run the mise tool
package mise
import (
@@ -44,7 +45,7 @@ func New(cacheDir string) (*Mise, error) {
}, nil
}
// GetLatestVersion gets the latest version of a package matching the version constraint
// gets the latest version of a package matching the version constraint
func (m *Mise) GetLatestVersion(pkg, version string) (string, error) {
_, unlock, err := m.createAndLock(pkg)
if err != nil {
@@ -120,6 +121,11 @@ func (m *Mise) GetAllVersions(pkg, version string) ([]string, error) {
return versions, nil
}
// GetCurrentList returns the JSON output of 'mise list --current --json' for a specific app directory
func (m *Mise) GetCurrentList(appDir string) (string, error) {
return m.runCmd("--cd", appDir, "list", "--current", "--json")
}
// runCmd runs a mise command with the given arguments
func (m *Mise) runCmd(args ...string) (string, error) {
cacheDir := filepath.Join(m.cacheDir, "cache")
@@ -180,7 +186,7 @@ func GenerateMiseToml(packages map[string]string) (string, error) {
return buf.String(), nil
}
// createAndLock creates a file mutex and locks it, returning the mutex and an unlock function
// lock ensuring mise does not work on the same package concurrently
func (m *Mise) createAndLock(pkg string) (*filemutex.FileMutex, func(), error) {
fileLockPath := filepath.Join(m.cacheDir, fmt.Sprintf("lock-%s", strings.ReplaceAll(pkg, "/", "-")))
mu, err := filemutex.New(fileLockPath)
+10
View File
@@ -314,26 +314,36 @@ func (p *PythonProvider) InstallMisePackages(ctx *generate.GenerateContext, mise
miseStep.Version(python, pipfileVersion, fmt.Sprintf("Pipfile > %s", pipfileVarName))
}
// Collect all packages that will be used by the provider
packages := []string{"python"}
// Install package managers
if p.hasPoetry(ctx) || p.hasUv(ctx) || p.hasPdm(ctx) || p.hasPipfile(ctx) {
miseStep.Default("pipx", "latest")
packages = append(packages, "pipx")
}
if p.hasPoetry(ctx) {
miseStep.Default("pipx:poetry", "latest")
packages = append(packages, "pipx:poetry")
}
if p.hasPdm(ctx) {
miseStep.Default("pipx:pdm", "latest")
packages = append(packages, "pipx:pdm")
}
if p.hasUv(ctx) {
miseStep.Default("pipx:uv", "latest")
packages = append(packages, "pipx:uv")
}
if p.hasPipfile(ctx) {
miseStep.Default("pipx:pipenv", "latest")
packages = append(packages, "pipx:pipenv")
}
miseStep.UseMiseVersions(ctx, packages)
}
func (p *PythonProvider) GetPythonEnvVars(ctx *generate.GenerateContext) map[string]string {
@@ -99,6 +99,23 @@ _Note the `docker load` here to load the image into Docker. However, you can
change the [output](https://github.com/moby/buildkit?tab=readme-ov-file#output)
or push to a registry instead._
## Integration Tests
Integration tests build and run example applications in containers to verify
end-to-end functionality. Each example with a `test.json` file gets tested
automatically.
```bash
# Run all integration tests, this takes a long time. Let CI do this for you.
mise run test-integration
# Run specific test
mise run test-integration -- -run "TestExamplesIntegration/python-uv-tool-versions"
```
The `test.json` file contains an array of test cases. Each case builds and runs the same
image but checks for different expected output strings.
## Mise commands
```bash
@@ -0,0 +1,2 @@
uv 0.7.22
python 3.9.23
+11
View File
@@ -0,0 +1,11 @@
import sys
import subprocess
print("Hello from Python UV!")
print(f"Python version: {sys.version.split()[0]}")
try:
result = subprocess.run(["uv", "--version"], capture_output=True, text=True, check=True)
print(f"UV version: {result.stdout.strip().split()[1]}")
except (subprocess.CalledProcessError, FileNotFoundError):
print("UV version: not available")
@@ -0,0 +1,7 @@
[project]
name = "python-specific-versions"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.11"
dependencies = []
@@ -0,0 +1,9 @@
[
{
"expectedOutput": [
"Hello from Python UV!",
"Python version: 3.9.23",
"UV version: 0.7.22"
]
}
]
+8
View File
@@ -0,0 +1,8 @@
version = 1
revision = 2
requires-python = ">=3.11"
[[package]]
name = "python-specific-versions"
version = "0.1.0"
source = { virtual = "." }
+46 -11
View File
@@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
"time"
@@ -23,9 +24,28 @@ import (
var buildkitCacheImport = flag.String("buildkit-cache-import", "", "BuildKit cache import configuration")
var buildkitCacheExport = flag.String("buildkit-cache-export", "", "BuildKit cache export configuration")
type StringOrArray []string
func (s *StringOrArray) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err == nil {
*s = []string{str}
return nil
}
var arr []string
if err := json.Unmarshal(data, &arr); err != nil {
return err
}
*s = arr
return nil
}
type TestCase struct {
ExpectedOutput string `json:"expectedOutput"`
Platform string `json:"platform"`
Platform string `json:"platform"`
// matches against the entire output of the container if it cannot be found in a single line
ExpectedOutput StringOrArray `json:"expectedOutput"`
Envs map[string]string `json:"envs"`
ConfigFilePath string `json:"configFile"`
JustBuild bool `json:"justBuild"`
@@ -50,6 +70,7 @@ func TestExamplesIntegration(t *testing.T) {
continue
}
// read in the test assertions in each example and generate a unique test case for each entry in the JSON array
testConfigPath := filepath.Join(examplesDir, entry.Name(), "test.json")
if _, err := os.Stat(testConfigPath); os.IsNotExist(err) {
continue
@@ -127,13 +148,15 @@ func TestExamplesIntegration(t *testing.T) {
}
}
// wait until cmd subprocess exits
func cmdDoneChan(cmd *exec.Cmd) chan error {
ch := make(chan error, 1)
go func() { ch <- cmd.Wait() }()
return ch
}
func runContainerWithTimeout(t *testing.T, imageName, expectedOutput string, envs map[string]string, platformStr string) error {
// this is run for each test case generated by test.json
func runContainerWithTimeout(t *testing.T, imageName string, expectedOutputs []string, envs map[string]string, platformStr string) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
@@ -177,14 +200,26 @@ func runContainerWithTimeout(t *testing.T, imageName, expectedOutput string, env
_ = rmCmd.Run()
}()
var output, errOutput strings.Builder
// read container output and look for expected output
var output, stdErrOutput strings.Builder
done := make(chan error, 1)
go func() {
foundOutputs := make([]bool, len(expectedOutputs))
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
output.WriteString(line + "\n")
if strings.Contains(line, expectedOutput) {
// Check if this line contains any of the expected outputs
for i, expected := range expectedOutputs {
if !foundOutputs[i] && strings.Contains(line, expected) {
foundOutputs[i] = true
}
}
// Check if all expected outputs have been found
if !slices.Contains(foundOutputs, false) {
done <- nil
return
}
@@ -193,13 +228,13 @@ func runContainerWithTimeout(t *testing.T, imageName, expectedOutput string, env
done <- fmt.Errorf("error reading stdout: %v", err)
return
}
done <- fmt.Errorf("container output:\n%s\nErrors:\n%s", output.String(), errOutput.String())
done <- fmt.Errorf("container stdout output:\n%s\nStderr output:\n%s", output.String(), stdErrOutput.String())
}()
go func() {
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
errOutput.WriteString(scanner.Text() + "\n")
stdErrOutput.WriteString(scanner.Text() + "\n")
}
}()
@@ -208,15 +243,15 @@ func runContainerWithTimeout(t *testing.T, imageName, expectedOutput string, env
return fmt.Errorf("container timed out after 2 minutes")
case err := <-done:
if err != nil {
require.Contains(t, output.String(), expectedOutput, "container output did not contain expected string")
return err
return fmt.Errorf("%v\ncontainer output did not contain expected string:\n%v", err, expectedOutputs)
}
// if err == nil, then we found the expected output, and can exit successfully before the container exits
return nil
case err := <-cmdDoneChan(cmd):
if err != nil && !strings.Contains(err.Error(), "signal: killed") {
return fmt.Errorf("container failed: %v", err)
}
require.Contains(t, output.String(), expectedOutput, "container output did not contain expected string")
return nil
return fmt.Errorf("container output did not contain expected string:\n%v", expectedOutputs)
}
}
+2
View File
@@ -61,3 +61,5 @@ node = "22"
[settings]
experimental = true
# without this, navigating into various examples/ will output an annoying warning
idiomatic_version_file_enable_tools = ["python", "node", "ruby"]