2025-03-03 15:05:18 +00:00
|
|
|
package patchwheel
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bytes"
|
2025-03-03 15:44:05 +00:00
|
|
|
"context"
|
2025-03-03 15:05:18 +00:00
|
|
|
"os"
|
|
|
|
"os/exec"
|
|
|
|
"path/filepath"
|
|
|
|
"strings"
|
|
|
|
"testing"
|
2025-03-03 16:25:12 +00:00
|
|
|
|
|
|
|
"github.com/stretchr/testify/require"
|
2025-03-03 15:05:18 +00:00
|
|
|
)
|
|
|
|
|
2025-03-03 16:25:12 +00:00
|
|
|
// Variants -- existing env
|
|
|
|
// Clean install
|
|
|
|
// Install unpatched first
|
|
|
|
// Install patched then another patched
|
|
|
|
|
|
|
|
// Variants -- source setup.py vs pyproject
|
|
|
|
// Different build backends? setuptools vs hatchling vs flit?
|
|
|
|
|
|
|
|
// Different tools? e.g. test poetry? test pdm? test regular pip?
|
|
|
|
|
|
|
|
// Variants -- python versions
|
|
|
|
|
|
|
|
// Variants --
|
|
|
|
|
2025-03-03 15:05:18 +00:00
|
|
|
// minimalPythonProject returns a map of file paths to their contents for a minimal Python project.
|
|
|
|
func minimalPythonProject() map[string]string {
|
|
|
|
return map[string]string{
|
2025-03-03 15:44:14 +00:00
|
|
|
"pyproject.toml": `[project]
|
|
|
|
name = "myproj"
|
|
|
|
version = "0.1.0"
|
|
|
|
|
|
|
|
[build-system]
|
|
|
|
requires = ["setuptools>=61.0.0", "wheel"]
|
|
|
|
build-backend = "setuptools.build_meta"
|
|
|
|
|
|
|
|
[tool.setuptools.packages.find]
|
2025-03-03 16:25:12 +00:00
|
|
|
where = ["src"]
|
2025-03-03 15:44:14 +00:00
|
|
|
`,
|
2025-03-03 16:25:12 +00:00
|
|
|
"src/myproj/__init__.py": `
|
2025-03-03 15:05:18 +00:00
|
|
|
def hello():
|
|
|
|
return "Hello, world!"
|
2025-03-03 16:25:12 +00:00
|
|
|
|
|
|
|
def print_version():
|
|
|
|
from importlib.metadata import version
|
|
|
|
print(version("myproj"))
|
2025-03-03 15:05:18 +00:00
|
|
|
`,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func writeProjectFiles(baseDir string, files map[string]string) error {
|
|
|
|
for path, content := range files {
|
|
|
|
fullPath := filepath.Join(baseDir, path)
|
|
|
|
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2025-03-03 19:37:05 +00:00
|
|
|
if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil {
|
2025-03-03 15:05:18 +00:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2025-03-03 15:51:02 +00:00
|
|
|
func runCmd(t *testing.T, dir, name string, args ...string) {
|
2025-03-03 19:34:44 +00:00
|
|
|
out := captureOutput(t, dir, name, args...)
|
|
|
|
if len(out) > 0 {
|
|
|
|
t.Errorf("Output from %s %s:\n%s", name, args, out)
|
2025-03-03 15:51:02 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func captureOutput(t *testing.T, dir, name string, args ...string) string {
|
|
|
|
cmd := exec.Command(name, args...)
|
|
|
|
cmd.Dir = dir
|
|
|
|
var out bytes.Buffer
|
|
|
|
cmd.Stdout = &out
|
|
|
|
cmd.Stderr = &out
|
|
|
|
err := cmd.Run()
|
|
|
|
if err != nil {
|
|
|
|
t.Logf("Command failed: %s %s", name, strings.Join(args, " "))
|
|
|
|
t.Logf("Output:\n%s", out.String())
|
|
|
|
t.Fatal(err)
|
|
|
|
}
|
|
|
|
return out.String()
|
2025-03-03 15:05:18 +00:00
|
|
|
}
|
|
|
|
|
2025-03-03 16:25:12 +00:00
|
|
|
func getWheel(t *testing.T, dir string) string {
|
|
|
|
pattern := filepath.Join(dir, "*.whl")
|
|
|
|
matches, err := filepath.Glob(pattern)
|
|
|
|
if err != nil {
|
|
|
|
t.Fatalf("Error matching pattern %s: %v", pattern, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(matches) == 0 {
|
|
|
|
t.Fatalf("No files found matching %s", pattern)
|
|
|
|
return ""
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(matches) != 1 {
|
|
|
|
t.Fatalf("Too many matches %s: %v", pattern, matches)
|
|
|
|
return ""
|
|
|
|
}
|
|
|
|
|
|
|
|
return matches[0]
|
|
|
|
}
|
|
|
|
|
2025-03-03 19:42:22 +00:00
|
|
|
// TestExtractVersionFromWheelFilename tests the ExtractVersionFromWheelFilename function.
|
|
|
|
func TestExtractVersionFromWheelFilename(t *testing.T) {
|
|
|
|
tests := []struct {
|
|
|
|
filename string
|
|
|
|
wantVersion string
|
|
|
|
wantErr bool
|
|
|
|
}{
|
|
|
|
{"myproj-0.1.0-py3-none-any.whl", "0.1.0", false},
|
|
|
|
{"myproj-0.1.0+20240303123456-py3-none-any.whl", "0.1.0+20240303123456", false},
|
|
|
|
{"my-proj-with-hyphens-0.1.0-py3-none-any.whl", "0.1.0", false},
|
|
|
|
{"invalid-filename.txt", "", true},
|
|
|
|
{"not-enough-parts-py3.whl", "", true},
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
t.Run(tt.filename, func(t *testing.T) {
|
|
|
|
gotVersion, err := ExtractVersionFromWheelFilename(tt.filename)
|
|
|
|
if tt.wantErr {
|
|
|
|
require.Error(t, err)
|
|
|
|
} else {
|
|
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, tt.wantVersion, gotVersion)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-03-03 15:05:18 +00:00
|
|
|
// TestPatchWheel tests PatchWheel with several Python versions.
|
|
|
|
func TestPatchWheel(t *testing.T) {
|
|
|
|
pythonVersions := []string{"python3.9", "python3.10", "python3.11", "python3.12"}
|
|
|
|
for _, py := range pythonVersions {
|
|
|
|
t.Run(py, func(t *testing.T) {
|
2025-03-03 15:44:14 +00:00
|
|
|
tempDir := t.TempDir()
|
2025-03-03 19:34:44 +00:00
|
|
|
// tempDir, err := os.MkdirTemp("", "pythontestdir")
|
|
|
|
// t.Logf("tempDir=%s", tempDir)
|
2025-03-03 15:05:18 +00:00
|
|
|
|
|
|
|
// Write minimal Python project files.
|
|
|
|
projFiles := minimalPythonProject()
|
|
|
|
if err := writeProjectFiles(tempDir, projFiles); err != nil {
|
|
|
|
t.Fatal(err)
|
|
|
|
}
|
|
|
|
|
2025-03-03 19:34:44 +00:00
|
|
|
runCmd(t, tempDir, "uv", "venv", "-q", "--python", py)
|
2025-03-03 15:05:18 +00:00
|
|
|
|
2025-03-03 19:34:44 +00:00
|
|
|
runCmd(t, tempDir, "uv", "build", "-q", "--wheel")
|
2025-03-03 15:05:18 +00:00
|
|
|
distDir := filepath.Join(tempDir, "dist")
|
2025-03-03 16:25:12 +00:00
|
|
|
origWheel := getWheel(t, distDir)
|
2025-03-03 19:34:44 +00:00
|
|
|
// t.Logf("Found origWheel: %s", origWheel)
|
2025-03-03 15:05:18 +00:00
|
|
|
|
2025-03-03 16:25:12 +00:00
|
|
|
patchedWheel, err := PatchWheel(context.Background(), origWheel, distDir)
|
|
|
|
require.NoError(t, err)
|
2025-03-03 19:34:44 +00:00
|
|
|
// t.Logf("origWheel=%s patchedWheel=%s", origWheel, patchedWheel)
|
2025-03-03 15:05:18 +00:00
|
|
|
|
2025-03-03 19:42:22 +00:00
|
|
|
// Test idempotency - patching the same wheel again should produce the same result
|
|
|
|
patchedWheel2, err := PatchWheel(context.Background(), origWheel, distDir)
|
|
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, patchedWheel, patchedWheel2, "PatchWheel is not idempotent")
|
|
|
|
|
2025-03-03 19:34:44 +00:00
|
|
|
runCmd(t, tempDir, "uv", "pip", "install", "-q", patchedWheel)
|
2025-03-03 15:05:18 +00:00
|
|
|
|
2025-03-03 16:25:12 +00:00
|
|
|
pyExec := filepath.Join(tempDir, ".venv", "bin", "python") // XXX Windows
|
|
|
|
cmdOut := captureOutput(t, tempDir, pyExec, "-c", "import myproj; myproj.print_version()")
|
2025-03-03 15:05:18 +00:00
|
|
|
version := strings.TrimSpace(cmdOut)
|
2025-03-03 16:25:12 +00:00
|
|
|
if !strings.HasPrefix(version, "0.1.0+20") {
|
|
|
|
t.Fatalf("expected version to start with 0.1.0+20, got %s", version)
|
2025-03-03 15:05:18 +00:00
|
|
|
}
|
2025-03-03 19:34:44 +00:00
|
|
|
// t.Logf("Tested %s: patched version = %s", py, version)
|
|
|
|
|
|
|
|
// TODO: install one more patched wheel (add an option to PatchWheel to add extra to timestamp)
|
2025-03-03 15:05:18 +00:00
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|