diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go index 9e93521ea1f..2a06480f5a6 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go @@ -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) @@ -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 @@ -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 { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go index c57cee03665..60a9e70b5e0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go @@ -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.