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
1 change: 1 addition & 0 deletions cli/azd/.vscode/cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ words:
- opencode
- grpcbroker
- msiexec
- manylinux
- nosec
- npx
- oneof
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
v1212 marked this conversation as resolved.
}

// 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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
v1212 marked this conversation as resolved.
}
}
}

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading