From 992533fe7735fb319174a4510a0c26a3fc17037f Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Tue, 7 Jul 2026 14:42:35 -0400 Subject: [PATCH 1/2] fix(ext-agents): select a compatible Python for agent run instead of first on PATH azd ai agent run's pip fallback resolved Python via findSystemPython, which returned the first of python3/python/py on PATH and then hard-failed in checkPythonVersion if that interpreter was too old. On Windows, python.exe is frequently an older version (e.g. 3.11) even when a newer Python (3.13) is installed and selectable via the 'py -3' launcher, so 'azd ai agent run' failed with 'Python 3.13+ is required (found 3.11.9)'. findSystemPython now probes multiple candidates -- preferring the Windows 'py -3' launcher, which selects the newest installed Python 3 -- and checks each one's version, returning the first that satisfies the minimum runtime (>= 3.13). When every candidate is too old, the error names the newest version found. The interpreter is modeled as an executable plus launcher args so a 'py -3' selection is carried through to venv creation. Fixes #8954 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extensions/azure.ai.agents/CHANGELOG.md | 1 + .../azure.ai.agents/internal/cmd/run.go | 156 +++++++++++++---- .../azure.ai.agents/internal/cmd/run_test.go | 161 ++++++++++++++---- 3 files changed, 254 insertions(+), 64 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index 3c2668aff60..297909db4ed 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -4,6 +4,7 @@ ### Bugs Fixed +- [[#8954]](https://github.com/Azure/azure-dev/issues/8954) Fix `azd ai agent run` failing with `Python 3.13+ is required` when a compatible Python is installed but not first on `PATH`. When falling back to `pip`, azd now probes multiple interpreters (including the Windows `py -3` launcher, which selects the newest installed Python 3) and checks each one's version, selecting the first that satisfies the runtime instead of hard-failing on whichever `python` appears first on `PATH`. - [[#8987]](https://github.com/Azure/azure-dev/pull/8987) Fix `azd ai agent init -m ` not prompting for the agent name. The prompt default and project folder are now derived from the manifest's `template.name` (falling back to the top-level `name`), matching the interactive and template flows. - [[#8981]](https://github.com/Azure/azure-dev/pull/8981) Fix `azd ai agent init -m --deploy-mode container` not resolving a container registry when adopting a unified Foundry `azure.yaml` on an existing Foundry project, which made `azd deploy` fail with `could not determine container registry endpoint`. The deploy mode is now resolved before Foundry project setup, so a container agent wires `AZURE_CONTAINER_REGISTRY_ENDPOINT` (or is signaled to create one on provision) while code deploy and `--image` still skip ACR. 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 a54436e6647..bcb43d23d58 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go @@ -616,15 +616,11 @@ func installPythonDepsPip(projectDir string) error { return fmt.Errorf("failed to check .venv: %w", err) case os.IsNotExist(err): fmt.Println("Setting up Python environment...") - pythonBin, findErr := findSystemPython() + python, findErr := findSystemPython() if findErr != nil { return findErr } - if err := checkPythonVersion(pythonBin); err != nil { - return err - } - //nolint:gosec // G204: venvDir is derived from the project directory path - cmd := exec.Command(pythonBin, "-m", "venv", venvDir) + cmd := python.command("-m", "venv", venvDir) cmd.Dir = projectDir cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -773,58 +769,150 @@ func venvPip(venvDir string) string { return filepath.Join(venvDir, "bin", "pip") } -func findSystemPython() (string, error) { - candidates := []string{"python3", "python"} +// Minimum supported system Python runtime. Kept in sync with the uv path's +// `uv venv --python ">=3.13"` constraint. +const ( + minPythonMajor = 3 + minPythonMinor = 13 +) + +// pythonInterpreter is a resolved interpreter invocation: the executable to run +// plus any leading launcher arguments needed to select a specific version (for +// example the Windows `py -3` launcher, which selects the newest installed +// Python 3 rather than whichever python.exe happens to appear first on PATH). +type pythonInterpreter struct { + // path is the resolved path to the interpreter or launcher (from LookPath). + path string + // args are leading arguments applied before any command (e.g. ["-3"]). + args []string +} + +// command builds an *exec.Cmd that invokes the interpreter with the provided +// trailing arguments, preserving any launcher args (e.g. `py -3 -m venv ...`). +func (p pythonInterpreter) command(extra ...string) *exec.Cmd { + args := append(slices.Clone(p.args), extra...) + //nolint:gosec // G204: path is an exec.LookPath result; args are static/derived + return exec.Command(p.path, args...) +} + +// pythonCandidates returns the ordered interpreter candidates to probe, limited +// to those that resolve on PATH. On Windows the `py -3` launcher is preferred +// because it selects the newest installed Python 3, which is frequently newer +// than the first python.exe on PATH. +func pythonCandidates() []pythonInterpreter { + type candidate struct { + name string + args []string + } + + var candidates []candidate if runtime.GOOS == "windows" { - candidates = append(candidates, "py") + candidates = []candidate{ + {"py", []string{"-3"}}, + {"python3", nil}, + {"python", nil}, + {"py", nil}, + } + } else { + candidates = []candidate{ + {"python3", nil}, + {"python", nil}, + } } - for _, name := range candidates { - if p, err := exec.LookPath(name); err == nil { - return p, nil + + var resolved []pythonInterpreter + for _, c := range candidates { + p, err := exec.LookPath(c.name) + if err != nil { + continue } + resolved = append(resolved, pythonInterpreter{path: p, args: c.args}) } - return "", fmt.Errorf( - "python not found on PATH. Install Python 3.13+ from https://www.python.org/downloads/") + return resolved } -// checkPythonVersion verifies the Python binary is >= 3.13 (matching the uv path's -// --python ">=3.13" constraint). Returns an error if the version is too old. -func checkPythonVersion(pythonBin string) error { - //nolint:gosec // G204: pythonBin is from findSystemPython (exec.LookPath result) - out, err := exec.Command(pythonBin, "--version").Output() +// pythonVersion runs `--version` on the interpreter and returns the parsed major +// and minor version numbers along with the raw version string (e.g. "3.13.2"). +func pythonVersion(interpreter pythonInterpreter) (major, minor int, raw string, err error) { + out, err := interpreter.command("--version").Output() if err != nil { - return fmt.Errorf("failed to check Python version: %w", err) + return 0, 0, "", fmt.Errorf("failed to check Python version: %w", err) } - // Output is like "Python 3.13.2" + // Output is like "Python 3.13.2". version := strings.TrimSpace(string(out)) parts := strings.SplitN(version, " ", 2) if len(parts) != 2 { - return fmt.Errorf("unexpected python --version output: %s", version) + return 0, 0, "", fmt.Errorf("unexpected python --version output: %s", version) } + raw = parts[1] - segments := strings.SplitN(parts[1], ".", 3) + segments := strings.SplitN(raw, ".", 3) if len(segments) < 2 { - return fmt.Errorf("unexpected python version format: %s", parts[1]) + return 0, 0, "", fmt.Errorf("unexpected python version format: %s", raw) } - major, err := strconv.Atoi(segments[0]) + major, err = strconv.Atoi(segments[0]) if err != nil { - return fmt.Errorf("unexpected python major version: %s", segments[0]) + return 0, 0, "", fmt.Errorf("unexpected python major version: %s", segments[0]) } - minor, err := strconv.Atoi(segments[1]) + minor, err = strconv.Atoi(segments[1]) if err != nil { - return fmt.Errorf("unexpected python minor version: %s", segments[1]) + return 0, 0, "", fmt.Errorf("unexpected python minor version: %s", segments[1]) } - if major < 3 || (major == 3 && minor < 13) { - return fmt.Errorf( - "Python 3.13+ is required (found %s). "+ - "Install Python 3.13+ from https://www.python.org/downloads/", - parts[1]) + return major, minor, raw, nil +} + +// pythonVersionOK reports whether the given version satisfies the minimum +// supported Python runtime. +func pythonVersionOK(major, minor int) bool { + return major > minPythonMajor || (major == minPythonMajor && minor >= minPythonMinor) +} + +// firstCompatiblePython returns the first candidate whose version satisfies the +// minimum supported runtime. Candidates whose version cannot be determined are +// skipped. If every resolvable candidate is too old, the error names the newest +// incompatible version found so the user knows what is on their machine. +func firstCompatiblePython( + candidates []pythonInterpreter, + versionFn func(pythonInterpreter) (int, int, string, error), +) (pythonInterpreter, error) { + newestRaw := "" + newestMajor, newestMinor := -1, -1 + for _, c := range candidates { + major, minor, raw, err := versionFn(c) + if err != nil { + continue + } + if pythonVersionOK(major, minor) { + return c, nil + } + if major > newestMajor || (major == newestMajor && minor > newestMinor) { + newestMajor, newestMinor, newestRaw = major, minor, raw + } } - return nil + if newestRaw != "" { + return pythonInterpreter{}, fmt.Errorf( + "Python %d.%d+ is required (found %s). "+ + "Install Python %d.%d+ from https://www.python.org/downloads/", + minPythonMajor, minPythonMinor, newestRaw, minPythonMajor, minPythonMinor) + } + + return pythonInterpreter{}, fmt.Errorf( + "no compatible Python found on PATH. "+ + "Install Python %d.%d+ from https://www.python.org/downloads/", + minPythonMajor, minPythonMinor) +} + +// findSystemPython locates a system Python interpreter that satisfies the +// minimum supported runtime (>= 3.13). It probes several candidates and checks +// each one's version rather than blindly using the first python on PATH, +// because on Windows the first python.exe on PATH is frequently older than the +// newest installed Python (which the `py -3` launcher can locate). +func findSystemPython() (pythonInterpreter, error) { + return firstCompatiblePython(pythonCandidates(), pythonVersion) } // appendFoundryEnvVars translates azd environment keys to FOUNDRY_* env vars that hosted 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 8c8df93ae30..1470ffd4aab 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 @@ -1011,56 +1011,157 @@ func TestVenvPip(t *testing.T) { } } -func TestFindSystemPython(t *testing.T) { - t.Run("finds python3 on PATH", func(t *testing.T) { - dir := t.TempDir() +func TestPythonVersionOK(t *testing.T) { + tests := []struct { + major, minor int + want bool + }{ + {3, 13, true}, + {3, 14, true}, + {4, 0, true}, + {3, 12, false}, + {3, 11, false}, + {2, 7, false}, + } + for _, tc := range tests { + if got := pythonVersionOK(tc.major, tc.minor); got != tc.want { + t.Errorf("pythonVersionOK(%d, %d) = %v, want %v", tc.major, tc.minor, got, tc.want) + } + } +} + +func TestFirstCompatiblePython(t *testing.T) { + // versionMap maps interpreter path -> version parts returned by the fake + // version function. A missing entry simulates a candidate that fails to + // report its version (e.g. a Windows Store stub) and should be skipped. + newVersionFn := func(versions map[string][3]any) func(pythonInterpreter) (int, int, string, error) { + return func(p pythonInterpreter) (int, int, string, error) { + v, ok := versions[p.path] + if !ok { + return 0, 0, "", errors.New("cannot run") + } + return v[0].(int), v[1].(int), v[2].(string), nil + } + } - // Create a fake python3 executable - name := "python3" - if runtime.GOOS == "windows" { - name = "python3.exe" + t.Run("prefers first compatible candidate", func(t *testing.T) { + // Mirrors the Windows repro: `python` (first on PATH) is 3.11 but the + // `py -3` launcher (probed first) selects 3.13. + candidates := []pythonInterpreter{ + {path: "py", args: []string{"-3"}}, + {path: "python", args: nil}, } - fakePython := filepath.Join(dir, name) - err := os.WriteFile(fakePython, []byte(""), 0o755) //nolint:gosec // G306: test binary needs exec permission + versionFn := newVersionFn(map[string][3]any{ + "py": {3, 13, "3.13.2"}, + "python": {3, 11, "3.11.9"}, + }) + + got, err := firstCompatiblePython(candidates, versionFn) if err != nil { - t.Fatal(err) + t.Fatalf("unexpected error: %v", err) + } + if got.path != "py" || !slices.Equal(got.args, []string{"-3"}) { + t.Errorf("got %+v, want py -3", got) + } + }) + + t.Run("skips too-old candidate for a later compatible one", func(t *testing.T) { + candidates := []pythonInterpreter{ + {path: "python3"}, + {path: "python"}, + } + versionFn := newVersionFn(map[string][3]any{ + "python3": {3, 11, "3.11.9"}, + "python": {3, 13, "3.13.0"}, + }) + + got, err := firstCompatiblePython(candidates, versionFn) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.path != "python" { + t.Errorf("got %q, want python", got.path) + } + }) + + t.Run("errors naming newest incompatible version", func(t *testing.T) { + candidates := []pythonInterpreter{ + {path: "python3"}, + {path: "python"}, + } + versionFn := newVersionFn(map[string][3]any{ + "python3": {3, 11, "3.11.9"}, + "python": {3, 12, "3.12.4"}, + }) + + _, err := firstCompatiblePython(candidates, versionFn) + if err == nil { + t.Fatal("expected an error when all candidates are too old") } + if !strings.Contains(err.Error(), "3.13+ is required") || + !strings.Contains(err.Error(), "3.12.4") { + t.Errorf("error should name the required and newest-found versions, got: %v", err) + } + }) - t.Setenv("PATH", dir) - result, err := findSystemPython() + t.Run("skips candidates whose version cannot be determined", func(t *testing.T) { + candidates := []pythonInterpreter{ + {path: "broken"}, + {path: "python"}, + } + versionFn := newVersionFn(map[string][3]any{ + // "broken" intentionally absent -> versionFn returns an error. + "python": {3, 13, "3.13.1"}, + }) + + got, err := firstCompatiblePython(candidates, versionFn) if err != nil { t.Fatalf("unexpected error: %v", err) } - if result != fakePython { - t.Errorf("expected %q, got %q", fakePython, result) + if got.path != "python" { + t.Errorf("got %q, want python", got.path) } }) - t.Run("returns error when no python on PATH", func(t *testing.T) { - dir := t.TempDir() // empty directory - t.Setenv("PATH", dir) - _, err := findSystemPython() + t.Run("errors when no candidates resolve a version", func(t *testing.T) { + _, err := firstCompatiblePython(nil, newVersionFn(nil)) if err == nil { - t.Fatal("expected error when no python on PATH") + t.Fatal("expected an error when there are no candidates") + } + if !strings.Contains(err.Error(), "no compatible Python") { + t.Errorf("unexpected error: %v", err) } }) } -func TestCheckPythonVersion(t *testing.T) { - // This test requires a real python on PATH to run --version. - // Skip if python is not available or can't execute. - pythonBin, err := findSystemPython() - if err != nil { +func TestFindSystemPython_NoPython(t *testing.T) { + dir := t.TempDir() // empty directory, no python on PATH + t.Setenv("PATH", dir) + if _, err := findSystemPython(); err == nil { + t.Fatal("expected error when no python on PATH") + } +} + +func TestPythonVersion(t *testing.T) { + // Requires a real python on PATH to run --version. + candidates := pythonCandidates() + if len(candidates) == 0 { t.Skip("python not available on PATH") } - err = checkPythonVersion(pythonBin) + major, minor, raw, err := pythonVersion(candidates[0]) if err != nil { - // Accept either a version-too-old error or an execution error - // (e.g., Windows Store stub that LookPath finds but can't run). - if !strings.Contains(err.Error(), "3.13+ is required") && - !strings.Contains(err.Error(), "failed to check Python version") { - t.Errorf("unexpected error: %v", err) + // A Windows Store stub can be found by LookPath but fail to execute. + if strings.Contains(err.Error(), "failed to check Python version") { + t.Skip("python present but not executable") } + t.Fatalf("unexpected error: %v", err) + } + if major <= 0 { + t.Errorf("expected a positive major version, got %d", major) + } + if raw == "" { + t.Error("expected a non-empty raw version string") } + _ = minor } From ff179a525345f84d290b776c33671c3d02144491 Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Wed, 8 Jul 2026 09:35:48 -0400 Subject: [PATCH 2/2] address review: derive uv --python spec from the shared min-version constants The uv path hardcoded --python ">=3.13" while the pip path used the minPythonMajor/minPythonMinor constants, so the two could drift out of sync. Add minPythonUvSpec(), which builds the uv specifier from the constants, use it in the uv venv command, and add a test asserting it tracks the constants. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extensions/azure.ai.agents/internal/cmd/run.go | 14 +++++++++++--- .../azure.ai.agents/internal/cmd/run_test.go | 10 ++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) 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 bcb43d23d58..296c72bc668 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go @@ -567,7 +567,7 @@ 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", ">=3.13") //nolint:gosec // G204: venvDir is derived from the project directory path + cmd := exec.Command("uv", "venv", venvDir, "--python", minPythonUvSpec()) //nolint:gosec // G204: venvDir is derived from the project directory path cmd.Dir = projectDir cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -769,13 +769,21 @@ func venvPip(venvDir string) string { return filepath.Join(venvDir, "bin", "pip") } -// Minimum supported system Python runtime. Kept in sync with the uv path's -// `uv venv --python ">=3.13"` constraint. +// Minimum supported system Python runtime. This is the single source of truth +// for the required Python version across both the uv and pip installation +// paths (see minPythonUvSpec and the version checks below). const ( minPythonMajor = 3 minPythonMinor = 13 ) +// minPythonUvSpec returns the minimum runtime as a uv `--python` version +// specifier (e.g. ">=3.13"), derived from the constants so the uv path stays in +// sync with the pip-path version checks. +func minPythonUvSpec() string { + return fmt.Sprintf(">=%d.%d", minPythonMajor, minPythonMinor) +} + // pythonInterpreter is a resolved interpreter invocation: the executable to run // plus any leading launcher arguments needed to select a specific version (for // example the Windows `py -3` launcher, which selects the newest installed 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 1470ffd4aab..70350e30ab3 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 @@ -7,6 +7,7 @@ import ( "bytes" "context" "errors" + "fmt" "io" "net" "net/http" @@ -1011,6 +1012,15 @@ func TestVenvPip(t *testing.T) { } } +func TestMinPythonUvSpec(t *testing.T) { + // The uv `--python` specifier must derive from the shared constants so the + // uv and pip paths cannot drift apart. + want := fmt.Sprintf(">=%d.%d", minPythonMajor, minPythonMinor) + if got := minPythonUvSpec(); got != want { + t.Errorf("minPythonUvSpec() = %q, want %q", got, want) + } +} + func TestPythonVersionOK(t *testing.T) { tests := []struct { major, minor int