diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 60b5c7ab853..fb5f0790c66 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -45,6 +45,7 @@ words: - opencode - grpcbroker - msiexec + - manylinux - nosec - npx - oneof diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_validate.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_validate.go index 311c8027283..f312e99bb9d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_validate.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_validate.go @@ -25,6 +25,7 @@ func validatePostInit(srcDir string, codeConfig *agent_yaml.CodeConfiguration) { } validateDotnetRuntimeVsCsproj(srcDir, codeConfig.Runtime) + validateBundledHint(srcDir, codeConfig) } // validateDotnetRuntimeVsCsproj checks whether the selected .NET runtime version is compatible @@ -122,3 +123,36 @@ func extractTargetFrameworkVersion(csprojContent string) int { } return version } + +// validateBundledHint prints guidance when Python bundled mode is selected, +// reminding the user to install dependencies before deploying. +func validateBundledHint(srcDir string, codeConfig *agent_yaml.CodeConfiguration) { + if codeConfig.DependencyResolution == nil || *codeConfig.DependencyResolution != "bundled" { + return + } + + // Only show hint for Python projects + if !strings.HasPrefix(codeConfig.Runtime, "python_") { + return + } + + // Check if requirements.txt exists + reqPath := filepath.Join(srcDir, "requirements.txt") + if _, err := os.Stat(reqPath); err != nil { + return + } + + // Extract Python version from runtime (e.g. "python_3_14" -> "3.14") + pythonVersion := strings.TrimPrefix(codeConfig.Runtime, "python_") + pythonVersion = strings.Replace(pythonVersion, "_", ".", 1) + + fmt.Printf("\n%s Bundled mode selected. Before deploying, install dependencies into the source directory.\n", + color.YellowString("NOTE:")) + fmt.Printf(" The deployment target is Linux x86_64 with Python %s. Example command:\n\n", pythonVersion) + fmt.Printf(" cd \"%s\"\n", srcDir) + fmt.Printf(" pip install -r requirements.txt -t . \\\n") + fmt.Printf(" --platform manylinux_2_17_x86_64 --platform linux_x86_64 --platform any \\\n") + fmt.Printf(" --python-version %s --implementation cp --only-binary=:all: --upgrade\n\n", pythonVersion) + fmt.Printf(" If some packages lack pre-built wheels, you may need to remove --only-binary=:all:\n") + fmt.Printf(" and build on a matching Linux environment instead.\n\n") +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_validate_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_validate_test.go index 6b0f0fe3a71..82e09f599ac 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_validate_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_validate_test.go @@ -129,3 +129,101 @@ func TestValidatePostInit_PythonSkipsValidation(t *testing.T) { } validatePostInit("/any/path", codeConfig) } + +func TestValidateBundledHint_PythonBundled(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte("azure-ai-agents>=1.0\n"), 0600) + if err != nil { + t.Fatal(err) + } + + bundled := "bundled" + codeConfig := &agent_yaml.CodeConfiguration{ + Runtime: "python_3_14", + EntryPoint: "main.py", + DependencyResolution: &bundled, + } + + output, _ := captureStdout(t, func() error { + validateBundledHint(dir, codeConfig) + return nil + }) + + if !strings.Contains(output, "NOTE:") { + t.Errorf("expected NOTE in output, got: %s", output) + } + if !strings.Contains(output, "Example command") { + t.Errorf("expected 'Example command' in output, got: %s", output) + } + if !strings.Contains(output, "--python-version 3.14") { + t.Errorf("expected python version 3.14 in output, got: %s", output) + } + if !strings.Contains(output, "--only-binary=:all:") { + t.Errorf("expected --only-binary flag in output, got: %s", output) + } +} + +func TestValidateBundledHint_RemoteBuildSkipped(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte("azure-ai-agents>=1.0\n"), 0600) + if err != nil { + t.Fatal(err) + } + + remoteBuild := "remote_build" + codeConfig := &agent_yaml.CodeConfiguration{ + Runtime: "python_3_13", + EntryPoint: "main.py", + DependencyResolution: &remoteBuild, + } + + output, _ := captureStdout(t, func() error { + validateBundledHint(dir, codeConfig) + return nil + }) + + if output != "" { + t.Errorf("expected no output for remote_build, got: %s", output) + } +} + +func TestValidateBundledHint_DotnetBundledSkipped(t *testing.T) { + dir := t.TempDir() + + bundled := "bundled" + codeConfig := &agent_yaml.CodeConfiguration{ + Runtime: "dotnet_9", + EntryPoint: "Agent.dll", + DependencyResolution: &bundled, + } + + output, _ := captureStdout(t, func() error { + validateBundledHint(dir, codeConfig) + return nil + }) + + if output != "" { + t.Errorf("expected no output for dotnet bundled, got: %s", output) + } +} + +func TestValidateBundledHint_NoRequirements(t *testing.T) { + dir := t.TempDir() + // No requirements.txt + + bundled := "bundled" + codeConfig := &agent_yaml.CodeConfiguration{ + Runtime: "python_3_13", + EntryPoint: "main.py", + DependencyResolution: &bundled, + } + + output, _ := captureStdout(t, func() error { + validateBundledHint(dir, codeConfig) + return nil + }) + + if output != "" { + t.Errorf("expected no output without requirements.txt, got: %s", output) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index 87b7e0c1219..d00d054851f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -114,6 +114,11 @@ const ( CodeInvalidFilePath = "invalid_file_path" ) +// Error codes for packaging/deploy errors. +const ( + CodeBundledDepsNotFound = "bundled_deps_not_found" +) + // Error codes for toolbox operations. const ( CodeInvalidToolbox = "invalid_toolbox" diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index aa33e2fdffc..51784fe6c1f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -1420,6 +1420,14 @@ func (p *AgentServiceTargetProvider) packageCodeDeploy(ctx context.Context, serv if isDotnet && isBundled { return p.packageDotnetBundled(srcDir) } + + // Python bundled: validate that dependencies are installed in srcDir + isPython := strings.HasPrefix(agentDef.CodeConfiguration.Runtime, "python_") + if isPython && isBundled { + if err := validatePythonBundledDeps(srcDir); err != nil { + return "", "", err + } + } } } @@ -1669,6 +1677,85 @@ func (p *AgentServiceTargetProvider) packageDotnetBundled(srcDir string) (string return tmpPath, sha256Hex, nil } +// validatePythonBundledDeps checks that a Python project in bundled mode has +// installed dependencies in the source directory. It looks for .dist-info +// directories which are always created by pip install --target. +// Only returns an error if requirements.txt exists AND has content AND no +// .dist-info directories are found — this avoids false positives. +func validatePythonBundledDeps(srcDir string) error { + // Check if requirements.txt exists and has non-empty content + reqPath := filepath.Join(srcDir, "requirements.txt") + data, err := os.ReadFile(reqPath) //nolint:gosec // path from internal state + if err != nil { + if errors.Is(err, os.ErrNotExist) { + // No requirements.txt — nothing to validate + return nil + } + return exterrors.Dependency( + exterrors.CodeInvalidFilePath, + fmt.Sprintf("failed to read requirements.txt: %s", err), + "check file permissions for "+reqPath, + ) + } + + // Check if requirements.txt has any non-comment, non-empty lines + hasRequirements := false + for line := range strings.SplitSeq(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed != "" && !strings.HasPrefix(trimmed, "#") { + hasRequirements = true + break + } + } + if !hasRequirements { + return nil + } + + // Look for any *.dist-info directory in srcDir (top-level only, which is + // where pip install --target . places them). Also check one level deep + // for common patterns like vendor/ or lib/. + entries, err := os.ReadDir(srcDir) + if err != nil { + return exterrors.Dependency( + exterrors.CodeInvalidFilePath, + fmt.Sprintf("failed to read source directory: %s", err), + "check that the source directory exists and is readable: "+srcDir, + ) + } + + for _, e := range entries { + if e.IsDir() && strings.HasSuffix(e.Name(), ".dist-info") { + // Found at least one installed package — pass + return nil + } + } + + // Check one level of subdirectories for .dist-info (e.g., vendor/, lib/) + for _, e := range entries { + if !e.IsDir() { + continue + } + subEntries, err := os.ReadDir(filepath.Join(srcDir, e.Name())) + if err != nil { + continue + } + for _, se := range subEntries { + if se.IsDir() && strings.HasSuffix(se.Name(), ".dist-info") { + return nil + } + } + } + + return exterrors.Dependency( + exterrors.CodeBundledDepsNotFound, + "bundled mode is configured but no installed packages were found in the source directory. "+ + "Dependencies must be installed locally before deploying", + "run: pip install -r requirements.txt -t \""+srcDir+"\""+ + " --platform manylinux_2_17_x86_64 --platform linux_x86_64 --platform any"+ + " --implementation cp --only-binary=:all:", + ) +} + // deployHostedCodeAgent deploys a code-based hosted agent via multipart ZIP upload. func (p *AgentServiceTargetProvider) deployHostedCodeAgent( ctx context.Context, diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go index a30fca62453..ec3b87fe8b1 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go @@ -1884,3 +1884,58 @@ func TestFilterServicesByName(t *testing.T) { require.Equal(t, services, filterServicesByName(services, ""), "empty name returns input unchanged (defensive)") } + +func TestValidatePythonBundledDeps_NoRequirements(t *testing.T) { + dir := t.TempDir() + // No requirements.txt — should pass + err := validatePythonBundledDeps(dir) + require.NoError(t, err) +} + +func TestValidatePythonBundledDeps_EmptyRequirements(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte("# just a comment\n\n"), 0600)) + + err := validatePythonBundledDeps(dir) + require.NoError(t, err) +} + +func TestValidatePythonBundledDeps_NoDepsInstalled(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte("azure-ai-agents>=1.0\n"), 0600)) + + err := validatePythonBundledDeps(dir) + require.Error(t, err) + require.Contains(t, err.Error(), "no installed packages were found") +} + +func TestValidatePythonBundledDeps_TopLevelDistInfo(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte("azure-ai-agents>=1.0\n"), 0600)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "azure_ai_agents-1.0.dist-info"), 0o750)) + + err := validatePythonBundledDeps(dir) + require.NoError(t, err) +} + +func TestValidatePythonBundledDeps_SubdirDistInfo(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte("azure-ai-agents>=1.0\n"), 0600)) + // Installed into vendor/ subdir + require.NoError(t, os.MkdirAll(filepath.Join(dir, "vendor", "azure_ai_agents-1.0.dist-info"), 0o750)) + + err := validatePythonBundledDeps(dir) + require.NoError(t, err) +} + +func TestValidatePythonBundledDeps_ErrorCodeCorrect(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte("some-package\n"), 0600)) + + err := validatePythonBundledDeps(dir) + require.Error(t, err) + + var localErr *azdext.LocalError + require.True(t, errors.As(err, &localErr)) + require.Equal(t, exterrors.CodeBundledDepsNotFound, localErr.Code) +}