Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 76 additions & 17 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -604,8 +604,25 @@ func installDependencies(projectDir string) error {
return nil
}

type dependencyInstallStep struct {
startMessage string
successMessage string
errorMessage string
args []string
}

func installPythonDeps(projectDir string) error {
if _, err := exec.LookPath("uv"); err != nil {
lockedProject := uvLockedProject(projectDir)
uvPath, err := exec.LookPath("uv")
if err != nil {
if lockedProject {
return fmt.Errorf(
"uv is required to synchronize dependencies from uv.lock; "+
"install it from https://docs.astral.sh/uv/: %w",
err,
)
}

fmt.Println("Warning: uv is not installed. Install it from https://docs.astral.sh/uv/")
fmt.Println("Falling back to pip...")
return installPythonDepsPip(projectDir)
Expand All @@ -614,7 +631,8 @@ func installPythonDeps(projectDir string) error {
venvDir := filepath.Join(projectDir, ".venv")
if _, err := os.Stat(venvDir); os.IsNotExist(err) {
fmt.Println("Setting up Python environment...")
cmd := exec.Command("uv", "venv", venvDir, "--python", minPythonUvSpec()) //nolint:gosec // G204: venvDir is derived from the project directory path
//nolint:gosec // G204: uvPath is from LookPath; venvDir is derived from the project directory path
cmd := exec.Command(uvPath, "venv", venvDir, "--python", minPythonUvSpec())
cmd.Dir = projectDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
Expand All @@ -625,31 +643,72 @@ func installPythonDeps(projectDir string) error {

pythonPath := venvPython(venvDir)

if fileExists(filepath.Join(projectDir, "pyproject.toml")) {
fmt.Println("Installing dependencies (pyproject.toml)...")
cmd := exec.Command("uv", "pip", "install", "-e", ".", "--python", pythonPath, "--prerelease", "allow", "--quiet") //nolint:gosec // G204: pythonPath is derived from the project venv directory
for _, step := range uvPythonInstallSteps(projectDir, pythonPath) {
fmt.Println(step.startMessage)
//nolint:gosec // G204: uvPath is from LookPath; command arguments are static or derived from the project venv
cmd := exec.Command(uvPath, step.args...)
cmd.Dir = projectDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("uv pip install failed: %w", err)
return fmt.Errorf("%s: %w", step.errorMessage, err)
}
fmt.Println(" ✓ Dependencies installed (pyproject.toml)")
fmt.Println(step.successMessage)
}

return nil
}

func uvLockedProject(projectDir string) bool {
return fileExists(filepath.Join(projectDir, "pyproject.toml")) &&
fileExists(filepath.Join(projectDir, "uv.lock"))
}

func uvPythonInstallSteps(projectDir string, pythonPath string) []dependencyInstallStep {
if uvLockedProject(projectDir) {
return []dependencyInstallStep{{
startMessage: "Synchronizing dependencies (uv.lock)...",
successMessage: " ✓ Dependencies synchronized (uv.lock)",
errorMessage: "uv sync failed",
args: []string{
"sync",
"--locked",
"--python", pythonPath,
"--quiet",
},
}}
}

steps := []dependencyInstallStep{}
if fileExists(filepath.Join(projectDir, "pyproject.toml")) {
steps = append(steps, dependencyInstallStep{
startMessage: "Installing dependencies (pyproject.toml)...",
successMessage: " ✓ Dependencies installed (pyproject.toml)",
errorMessage: "uv pip install failed",
args: []string{
"pip", "install", "-e", ".",
"--python", pythonPath,
"--prerelease", "allow",
"--quiet",
},
})
}

if fileExists(filepath.Join(projectDir, "requirements.txt")) {
fmt.Println("Installing dependencies (requirements.txt)...")
cmd := exec.Command("uv", "pip", "install", "-r", "requirements.txt", "--python", pythonPath, "--prerelease", "allow", "--quiet") //nolint:gosec // G204: pythonPath is derived from the project venv directory
cmd.Dir = projectDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("uv pip install failed: %w", err)
}
fmt.Println(" ✓ Dependencies installed (requirements.txt)")
steps = append(steps, dependencyInstallStep{
startMessage: "Installing dependencies (requirements.txt)...",
successMessage: " ✓ Dependencies installed (requirements.txt)",
errorMessage: "uv pip install failed",
args: []string{
"pip", "install", "-r", "requirements.txt",
"--python", pythonPath,
"--prerelease", "allow",
"--quiet",
},
})
}

return nil
return steps
}

func installPythonDepsPip(projectDir string) error {
Expand Down
101 changes: 101 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,107 @@ func TestVenvPip(t *testing.T) {
}
}

func TestUvPythonInstallSteps(t *testing.T) {
t.Parallel()

tests := []struct {
name string
files []string
wantSteps []dependencyInstallStep
}{
{
name: "uv lock is authoritative",
files: []string{"pyproject.toml", "uv.lock", "requirements.txt"},
wantSteps: []dependencyInstallStep{{
startMessage: "Synchronizing dependencies (uv.lock)...",
successMessage: " ✓ Dependencies synchronized (uv.lock)",
errorMessage: "uv sync failed",
args: []string{
"sync",
"--locked",
"--python", "python-path",
"--quiet",
},
}},
},
{
name: "pyproject without lock uses editable install",
files: []string{"pyproject.toml"},
wantSteps: []dependencyInstallStep{{
startMessage: "Installing dependencies (pyproject.toml)...",
successMessage: " ✓ Dependencies installed (pyproject.toml)",
errorMessage: "uv pip install failed",
args: []string{
"pip", "install", "-e", ".",
"--python", "python-path",
"--prerelease", "allow",
"--quiet",
},
}},
},
{
name: "requirements without lock keeps requirements install",
files: []string{"requirements.txt"},
wantSteps: []dependencyInstallStep{{
startMessage: "Installing dependencies (requirements.txt)...",
successMessage: " ✓ Dependencies installed (requirements.txt)",
errorMessage: "uv pip install failed",
args: []string{
"pip", "install", "-r", "requirements.txt",
"--python", "python-path",
"--prerelease", "allow",
"--quiet",
},
}},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

projectDir := t.TempDir()
for _, name := range tt.files {
if err := os.WriteFile(filepath.Join(projectDir, name), nil, 0o600); err != nil {
t.Fatal(err)
}
}

got := uvPythonInstallSteps(projectDir, "python-path")
if len(got) != len(tt.wantSteps) {
t.Fatalf("got %d steps, want %d: %#v", len(got), len(tt.wantSteps), got)
}
for i := range got {
if got[i].startMessage != tt.wantSteps[i].startMessage ||
got[i].successMessage != tt.wantSteps[i].successMessage ||
got[i].errorMessage != tt.wantSteps[i].errorMessage ||
!slices.Equal(got[i].args, tt.wantSteps[i].args) {
t.Errorf("step %d = %#v, want %#v", i, got[i], tt.wantSteps[i])
}
}
})
}
}

func TestInstallPythonDepsRequiresUvForLock(t *testing.T) {
projectDir := t.TempDir()
for _, name := range []string{"pyproject.toml", "uv.lock"} {
if err := os.WriteFile(filepath.Join(projectDir, name), nil, 0o600); err != nil {
t.Fatal(err)
}
}

t.Setenv("PATH", t.TempDir())

err := installPythonDeps(projectDir)
if err == nil {
t.Fatal("expected locked project without uv to fail")
}
if !strings.Contains(err.Error(), "uv is required to synchronize dependencies from uv.lock") {
t.Fatalf("unexpected error: %v", err)
}
}

func TestMinPythonUvSpec(t *testing.T) {
// The uv `--python` specifier must derive from the shared constants so the
// uv and pip paths cannot drift apart.
Expand Down
Loading