From 9d6e1d9cf30989f49f286d95d74996d7a0b67336 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Mon, 26 Jan 2026 13:22:50 -0800 Subject: [PATCH 01/12] feat(python): add support for creating and activating venv --- cmd/project/init.go | 2 +- internal/runtime/python/python.go | 144 +++++++++++----- internal/runtime/python/python_test.go | 229 +++++++++++++++++++++++++ 3 files changed, 331 insertions(+), 44 deletions(-) diff --git a/cmd/project/init.go b/cmd/project/init.go index 4ffe0089..1ac6db25 100644 --- a/cmd/project/init.go +++ b/cmd/project/init.go @@ -52,7 +52,7 @@ func NewInitCommand(clients *shared.ClientFactory) *cobra.Command { "Installs your project dependencies when supported:", "- Deno: Supported", "- Node.js: Supported", - "- Python: Unsupported", + "- Python: Supported (creates .venv if needed)", "", "Adds an existing app to your project (optional):", "- Prompts to add an existing app from app settings", diff --git a/internal/runtime/python/python.go b/internal/runtime/python/python.go index b6e81cf8..7c546e6c 100644 --- a/internal/runtime/python/python.go +++ b/internal/runtime/python/python.go @@ -18,6 +18,7 @@ import ( "context" _ "embed" "fmt" + "os/exec" "path/filepath" "regexp" "runtime" @@ -62,6 +63,56 @@ func (p *Python) IgnoreDirectories() []string { return []string{} } +// getVenvPath returns the path to the virtual environment directory +func getVenvPath(projectDirPath string) string { + return filepath.Join(projectDirPath, ".venv") +} + +// getPipExecutable returns the path to the pip executable in the virtual environment +func getPipExecutable(venvPath string) string { + if runtime.GOOS == "windows" { + return filepath.Join(venvPath, "Scripts", "pip.exe") + } + return filepath.Join(venvPath, "bin", "pip") +} + +// venvExists checks if a virtual environment exists at the given path +func venvExists(fs afero.Fs, venvPath string) bool { + pipPath := getPipExecutable(venvPath) + if _, err := fs.Stat(pipPath); err == nil { + return true + } + return false +} + +// createVirtualEnvironment creates a Python virtual environment +func createVirtualEnvironment(ctx context.Context, projectDirPath string) error { + cmd := exec.CommandContext(ctx, "python3", "-m", "venv", ".venv") + cmd.Dir = projectDirPath + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to create virtual environment: %w\nOutput: %s", err, string(output)) + } + return nil +} + +// runPipInstall runs pip install with the given arguments +func runPipInstall(ctx context.Context, venvPath string, projectDirPath string, args ...string) (string, error) { + pipPath := getPipExecutable(venvPath) + + // Build command: pip install [args] + cmdArgs := append([]string{"install"}, args...) + cmd := exec.CommandContext(ctx, pipPath, cmdArgs...) + cmd.Dir = projectDirPath + + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("pip install failed: %w", err) + } + + return string(output), nil +} + // installRequirementsTxt handles adding slack-cli-hooks to requirements.txt func installRequirementsTxt(fs afero.Fs, projectDirPath string) (output string, err error) { requirementsFilePath := filepath.Join(projectDirPath, "requirements.txt") @@ -189,8 +240,7 @@ func installPyProjectToml(fs afero.Fs, projectDirPath string) (output string, er return fmt.Sprintf("Updated pyproject.toml with %s", style.Highlight(slackCLIHooksPackageSpecifier)), nil } -// InstallProjectDependencies is unsupported by Python because a virtual environment is required before installing the project dependencies. -// TODO(@mbrooks) - should we confirm that the project is using Bolt Python? +// InstallProjectDependencies creates a virtual environment and installs project dependencies. func (p *Python) InstallProjectDependencies(ctx context.Context, projectDirPath string, hookExecutor hooks.HookExecutor, ios iostreams.IOStreamer, fs afero.Fs, os types.Os) (output string, err error) { var outputs []string var errs []error @@ -210,44 +260,26 @@ func (p *Python) InstallProjectDependencies(ctx context.Context, projectDirPath hasPyProjectToml = true } - // Defer a function to transform the return values - defer func() { - // Manual steps to setup virtual environment and install dependencies - var activateVirtualEnv = "source .venv/bin/activate" - if runtime.GOOS == "windows" { - activateVirtualEnv = `.venv\Scripts\activate` - } - - // Get the relative path to the project directory - var projectDirPathRel, _ = getProjectDirRelPath(os, os.GetExecutionDir(), projectDirPath) - - outputs = append(outputs, fmt.Sprintf("Manually setup a %s", style.Highlight("Python virtual environment"))) - if projectDirPathRel != "." { - outputs = append(outputs, fmt.Sprintf(" Change into the project: %s", style.CommandText(fmt.Sprintf("cd %s%s", filepath.Base(projectDirPathRel), string(filepath.Separator))))) - } - outputs = append(outputs, fmt.Sprintf(" Create virtual environment: %s", style.CommandText("python3 -m venv .venv"))) - outputs = append(outputs, fmt.Sprintf(" Activate virtual environment: %s", style.CommandText(activateVirtualEnv))) - - // Provide appropriate install command based on which file exists - if hasRequirementsTxt { - outputs = append(outputs, fmt.Sprintf(" Install project dependencies: %s", style.CommandText("pip install -r requirements.txt"))) - } - if hasPyProjectToml { - outputs = append(outputs, fmt.Sprintf(" Install project dependencies: %s", style.CommandText("pip install -e ."))) - } + // Ensure at least one dependency file exists + if !hasRequirementsTxt && !hasPyProjectToml { + err := fmt.Errorf("no Python dependency file found (requirements.txt or pyproject.toml)") + return fmt.Sprintf("Error: %s", err), err + } - outputs = append(outputs, fmt.Sprintf(" Learn more: %s", style.Underline("https://docs.python.org/3/tutorial/venv.html"))) + // Get virtual environment path + venvPath := getVenvPath(projectDirPath) - // Get first error or nil - var firstErr error - if len(errs) > 0 { - firstErr = errs[0] + // Create virtual environment if it doesn't exist + if !venvExists(fs, venvPath) { + outputs = append(outputs, "Creating Python virtual environment") + if err := createVirtualEnvironment(ctx, projectDirPath); err != nil { + outputs = append(outputs, fmt.Sprintf("Error creating virtual environment: %s", err)) + return strings.Join(outputs, "\n"), err } - - // Update return value - output = strings.Join(outputs, "\n") - err = firstErr - }() + outputs = append(outputs, fmt.Sprintf("Created virtual environment at %s", style.Highlight(".venv"))) + } else { + outputs = append(outputs, fmt.Sprintf("Found existing virtual environment at %s", style.Highlight(".venv"))) + } // Handle requirements.txt if it exists if hasRequirementsTxt { @@ -267,14 +299,40 @@ func (p *Python) InstallProjectDependencies(ctx context.Context, projectDirPath } } - // If neither file exists, return an error - if !hasRequirementsTxt && !hasPyProjectToml { - err := fmt.Errorf("no Python dependency file found (requirements.txt or pyproject.toml)") - errs = append(errs, err) - outputs = append(outputs, fmt.Sprintf("Error: %s", err)) + // Return early if we had errors updating dependency files + if len(errs) > 0 { + return strings.Join(outputs, "\n"), errs[0] } - return + // Install dependencies using pip + if hasRequirementsTxt { + outputs = append(outputs, "Installing dependencies from requirements.txt") + pipOutput, err := runPipInstall(ctx, venvPath, projectDirPath, "-r", "requirements.txt") + if err != nil { + errs = append(errs, err) + outputs = append(outputs, fmt.Sprintf("Error installing from requirements.txt: %s\n%s", err, pipOutput)) + } else { + outputs = append(outputs, "Successfully installed dependencies from requirements.txt") + } + } + + if hasPyProjectToml { + outputs = append(outputs, "Installing dependencies from pyproject.toml") + pipOutput, err := runPipInstall(ctx, venvPath, projectDirPath, "-e", ".") + if err != nil { + errs = append(errs, err) + outputs = append(outputs, fmt.Sprintf("Error installing from pyproject.toml: %s\n%s", err, pipOutput)) + } else { + outputs = append(outputs, "Successfully installed dependencies from pyproject.toml") + } + } + + // Return result + output = strings.Join(outputs, "\n") + if len(errs) > 0 { + return output, errs[0] + } + return output, nil } // Name prints the name of the runtime diff --git a/internal/runtime/python/python_test.go b/internal/runtime/python/python_test.go index 8b6c2574..c6b6148d 100644 --- a/internal/runtime/python/python_test.go +++ b/internal/runtime/python/python_test.go @@ -63,7 +63,236 @@ func Test_Python_IgnoreDirectories(t *testing.T) { } } +func Test_getVenvPath(t *testing.T) { + tests := []struct { + name string + projectDirPath string + expectedPath string + }{ + { + name: "Get venv path", + projectDirPath: "/path/to/project", + expectedPath: "/path/to/project/.venv", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getVenvPath(tt.projectDirPath) + require.Equal(t, tt.expectedPath, result) + }) + } +} + +func Test_getPipExecutable(t *testing.T) { + tests := []struct { + name string + venvPath string + expectedPath string + skipOnOS string + }{ + { + name: "Get pip path on Unix", + venvPath: "/path/to/.venv", + expectedPath: "/path/to/.venv/bin/pip", + skipOnOS: "windows", + }, + { + name: "Get pip path on Windows", + venvPath: "C:\\path\\to\\.venv", + expectedPath: "C:\\path\\to\\.venv\\Scripts\\pip.exe", + skipOnOS: "linux", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getPipExecutable(tt.venvPath) + // Only assert on the appropriate OS + if tt.skipOnOS != "" { + // Skip OS-specific test + return + } + require.Contains(t, result, "pip") + }) + } +} + +func Test_installRequirementsTxt(t *testing.T) { + tests := []struct { + name string + existingContent string + expectedContent string + expectedOutput string + expectedError bool + }{ + { + name: "Skip when slack-cli-hooks already exists", + existingContent: "slack-cli-hooks\npytest==8.3.2\nruff==0.7.2", + expectedContent: "slack-cli-hooks\npytest==8.3.2\nruff==0.7.2", + expectedOutput: "Found requirements.txt with", + expectedError: false, + }, + { + name: "Add after slack-bolt when it exists", + existingContent: "slack-bolt==2.31.2\npytest==8.3.2\nruff==0.7.2", + expectedContent: "slack-bolt==2.31.2\nslack-cli-hooks<1.0.0\npytest==8.3.2\nruff==0.7.2", + expectedOutput: "Updated requirements.txt with", + expectedError: false, + }, + { + name: "Add at end when slack-bolt doesn't exist", + existingContent: "pytest==8.3.2\nruff==0.7.2", + expectedContent: "pytest==8.3.2\nruff==0.7.2\nslack-cli-hooks<1.0.0", + expectedOutput: "Updated requirements.txt with", + expectedError: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := slackdeps.NewFsMock() + projectDirPath := "/path/to/project" + + // Create requirements.txt + requirementsPath := filepath.Join(projectDirPath, "requirements.txt") + err := fs.MkdirAll(projectDirPath, 0755) + require.NoError(t, err) + err = afero.WriteFile(fs, requirementsPath, []byte(tt.existingContent), 0644) + require.NoError(t, err) + + // Test + output, err := installRequirementsTxt(fs, projectDirPath) + + // Assertions + if tt.expectedError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + require.Contains(t, output, tt.expectedOutput) + + // Verify file content + content, err := afero.ReadFile(fs, requirementsPath) + require.NoError(t, err) + require.Equal(t, tt.expectedContent, string(content)) + }) + } +} + +func Test_installPyProjectToml(t *testing.T) { + tests := []struct { + name string + existingContent string + shouldContain []string + expectedOutput string + expectedError bool + }{ + { + name: "Skip when slack-cli-hooks already exists", + existingContent: `[project] +name = "my-app" +dependencies = ["slack-cli-hooks<1.0.0", "pytest==8.3.2"]`, + shouldContain: []string{"slack-cli-hooks"}, + expectedOutput: "Found pyproject.toml with", + expectedError: false, + }, + { + name: "Add after slack-bolt when it exists", + existingContent: `[project] +name = "my-app" +dependencies = ["slack-bolt>=1.0.0", "pytest==8.3.2"]`, + shouldContain: []string{"slack-bolt", "slack-cli-hooks", "pytest"}, + expectedOutput: "Updated pyproject.toml with", + expectedError: false, + }, + { + name: "Add at end when slack-bolt doesn't exist", + existingContent: `[project] +name = "my-app" +dependencies = ["pytest==8.3.2"]`, + shouldContain: []string{"slack-cli-hooks", "pytest"}, + expectedOutput: "Updated pyproject.toml with", + expectedError: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := slackdeps.NewFsMock() + projectDirPath := "/path/to/project" + + // Create pyproject.toml + pyprojectPath := filepath.Join(projectDirPath, "pyproject.toml") + err := fs.MkdirAll(projectDirPath, 0755) + require.NoError(t, err) + err = afero.WriteFile(fs, pyprojectPath, []byte(tt.existingContent), 0644) + require.NoError(t, err) + + // Test + output, err := installPyProjectToml(fs, projectDirPath) + + // Assertions + if tt.expectedError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + require.Contains(t, output, tt.expectedOutput) + + // Verify file content contains expected strings + content, err := afero.ReadFile(fs, pyprojectPath) + require.NoError(t, err) + for _, expected := range tt.shouldContain { + require.Contains(t, string(content), expected) + } + }) + } +} + +func Test_venvExists(t *testing.T) { + tests := []struct { + name string + venvPath string + createPipFile bool + expectedResult bool + }{ + { + name: "Venv exists when pip file present", + venvPath: "/path/to/.venv", + createPipFile: true, + expectedResult: true, + }, + { + name: "Venv does not exist when pip file absent", + venvPath: "/path/to/.venv", + createPipFile: false, + expectedResult: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := slackdeps.NewFsMock() + + if tt.createPipFile { + pipPath := getPipExecutable(tt.venvPath) + err := fs.MkdirAll(filepath.Dir(pipPath), 0755) + require.NoError(t, err) + err = afero.WriteFile(fs, pipPath, []byte(""), 0755) + require.NoError(t, err) + } + + result := venvExists(fs, tt.venvPath) + require.Equal(t, tt.expectedResult, result) + }) + } +} + +// Test_Python_InstallProjectDependencies tests the main function +// NOTE: These tests require Python 3 and pip to be installed for full integration testing. +// The function creates real virtual environments and runs real pip commands. +// Unit tests below focus on testing individual components. func Test_Python_InstallProjectDependencies(t *testing.T) { + t.Skip("Skipping integration tests - requires Python 3, pip, and real file system") + tests := map[string]struct { existingFiles map[string]string expectedFiles map[string]string From 665e23c60f98c6b35613dff99e1c392f350b9337 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 20 Feb 2026 11:35:24 -0800 Subject: [PATCH 02/12] fix(python): install pyproject.toml before requirements.txt Swap pip install order so pyproject.toml is installed first to set up the project package, then requirements.txt pins take precedence as the lockfile. --- internal/runtime/python/python.go | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/internal/runtime/python/python.go b/internal/runtime/python/python.go index 7c546e6c..f16e228a 100644 --- a/internal/runtime/python/python.go +++ b/internal/runtime/python/python.go @@ -305,25 +305,28 @@ func (p *Python) InstallProjectDependencies(ctx context.Context, projectDirPath } // Install dependencies using pip - if hasRequirementsTxt { - outputs = append(outputs, "Installing dependencies from requirements.txt") - pipOutput, err := runPipInstall(ctx, venvPath, projectDirPath, "-r", "requirements.txt") + // When both files exist, pyproject.toml is installed first to set up the project package + // and its declared dependencies. Then requirements.txt is installed second so its version + // pins take precedence, as it typically serves as the lockfile. + if hasPyProjectToml { + outputs = append(outputs, "Installing dependencies from pyproject.toml") + pipOutput, err := runPipInstall(ctx, venvPath, projectDirPath, "-e", ".") if err != nil { errs = append(errs, err) - outputs = append(outputs, fmt.Sprintf("Error installing from requirements.txt: %s\n%s", err, pipOutput)) + outputs = append(outputs, fmt.Sprintf("Error installing from pyproject.toml: %s\n%s", err, pipOutput)) } else { - outputs = append(outputs, "Successfully installed dependencies from requirements.txt") + outputs = append(outputs, "Successfully installed dependencies from pyproject.toml") } } - if hasPyProjectToml { - outputs = append(outputs, "Installing dependencies from pyproject.toml") - pipOutput, err := runPipInstall(ctx, venvPath, projectDirPath, "-e", ".") + if hasRequirementsTxt { + outputs = append(outputs, "Installing dependencies from requirements.txt") + pipOutput, err := runPipInstall(ctx, venvPath, projectDirPath, "-r", "requirements.txt") if err != nil { errs = append(errs, err) - outputs = append(outputs, fmt.Sprintf("Error installing from pyproject.toml: %s\n%s", err, pipOutput)) + outputs = append(outputs, fmt.Sprintf("Error installing from requirements.txt: %s\n%s", err, pipOutput)) } else { - outputs = append(outputs, "Successfully installed dependencies from pyproject.toml") + outputs = append(outputs, "Successfully installed dependencies from requirements.txt") } } From 9008d028affb941e08bd044498d40bd791860af5 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 20 Feb 2026 11:45:13 -0800 Subject: [PATCH 03/12] chore(cmd): remove venv detail from init command description --- cmd/project/init.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/project/init.go b/cmd/project/init.go index 1ac6db25..8c18fd20 100644 --- a/cmd/project/init.go +++ b/cmd/project/init.go @@ -52,7 +52,7 @@ func NewInitCommand(clients *shared.ClientFactory) *cobra.Command { "Installs your project dependencies when supported:", "- Deno: Supported", "- Node.js: Supported", - "- Python: Supported (creates .venv if needed)", + "- Python: Supported", "", "Adds an existing app to your project (optional):", "- Prompts to add an existing app from app settings", From 51030ee7c7e7ff8f72fef0a73c96565371f7ba28 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 20 Feb 2026 12:52:58 -0800 Subject: [PATCH 04/12] fix(python): use correct python executable on Windows for venv creation --- internal/runtime/python/python.go | 10 ++++++++- internal/runtime/python/python_test.go | 28 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/internal/runtime/python/python.go b/internal/runtime/python/python.go index f16e228a..68ce9621 100644 --- a/internal/runtime/python/python.go +++ b/internal/runtime/python/python.go @@ -68,6 +68,14 @@ func getVenvPath(projectDirPath string) string { return filepath.Join(projectDirPath, ".venv") } +// getPythonExecutable returns the Python executable name for the current OS +func getPythonExecutable() string { + if runtime.GOOS == "windows" { + return "python" + } + return "python3" +} + // getPipExecutable returns the path to the pip executable in the virtual environment func getPipExecutable(venvPath string) string { if runtime.GOOS == "windows" { @@ -87,7 +95,7 @@ func venvExists(fs afero.Fs, venvPath string) bool { // createVirtualEnvironment creates a Python virtual environment func createVirtualEnvironment(ctx context.Context, projectDirPath string) error { - cmd := exec.CommandContext(ctx, "python3", "-m", "venv", ".venv") + cmd := exec.CommandContext(ctx, getPythonExecutable(), "-m", "venv", ".venv") cmd.Dir = projectDirPath output, err := cmd.CombinedOutput() if err != nil { diff --git a/internal/runtime/python/python_test.go b/internal/runtime/python/python_test.go index c6b6148d..28672590 100644 --- a/internal/runtime/python/python_test.go +++ b/internal/runtime/python/python_test.go @@ -83,6 +83,34 @@ func Test_getVenvPath(t *testing.T) { } } +func Test_getPythonExecutable(t *testing.T) { + tests := []struct { + name string + expectedExecutable string + skipOnOS string + }{ + { + name: "Get python executable on Unix", + expectedExecutable: "python3", + skipOnOS: "windows", + }, + { + name: "Get python executable on Windows", + expectedExecutable: "python", + skipOnOS: "linux", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.skipOnOS != "" { + return + } + result := getPythonExecutable() + require.Equal(t, tt.expectedExecutable, result) + }) + } +} + func Test_getPipExecutable(t *testing.T) { tests := []struct { name string From 6e6ba42544e4e4139266c4205c5bffcb916cd5b1 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 20 Feb 2026 13:06:23 -0800 Subject: [PATCH 05/12] chore(python): add comment explaining venv activation is unnecessary for pip --- internal/runtime/python/python.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/runtime/python/python.go b/internal/runtime/python/python.go index 68ce9621..9150d0d6 100644 --- a/internal/runtime/python/python.go +++ b/internal/runtime/python/python.go @@ -104,7 +104,10 @@ func createVirtualEnvironment(ctx context.Context, projectDirPath string) error return nil } -// runPipInstall runs pip install with the given arguments +// runPipInstall runs pip install with the given arguments. +// The venv does not need to be activated because pip is invoked by its full +// path inside the venv, which ensures packages are installed into the venv's +// site-packages directory. func runPipInstall(ctx context.Context, venvPath string, projectDirPath string, args ...string) (string, error) { pipPath := getPipExecutable(venvPath) From cf35c5ce85dd604f9ae82098bd2576087c0186b9 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 20 Feb 2026 14:04:00 -0800 Subject: [PATCH 06/12] chore(python): fix gofmt formatting in python_test.go --- internal/runtime/python/python_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/runtime/python/python_test.go b/internal/runtime/python/python_test.go index 28672590..a1d676b4 100644 --- a/internal/runtime/python/python_test.go +++ b/internal/runtime/python/python_test.go @@ -85,19 +85,19 @@ func Test_getVenvPath(t *testing.T) { func Test_getPythonExecutable(t *testing.T) { tests := []struct { - name string + name string expectedExecutable string - skipOnOS string + skipOnOS string }{ { - name: "Get python executable on Unix", + name: "Get python executable on Unix", expectedExecutable: "python3", - skipOnOS: "windows", + skipOnOS: "windows", }, { - name: "Get python executable on Windows", + name: "Get python executable on Windows", expectedExecutable: "python", - skipOnOS: "linux", + skipOnOS: "linux", }, } for _, tt := range tests { From bff1d8c077a380251d2950036c2598de78265d43 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 20 Feb 2026 14:19:07 -0800 Subject: [PATCH 07/12] chore(python): move venv creation message to debug output --- internal/runtime/python/python.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/runtime/python/python.go b/internal/runtime/python/python.go index 9150d0d6..5233b04a 100644 --- a/internal/runtime/python/python.go +++ b/internal/runtime/python/python.go @@ -282,7 +282,7 @@ func (p *Python) InstallProjectDependencies(ctx context.Context, projectDirPath // Create virtual environment if it doesn't exist if !venvExists(fs, venvPath) { - outputs = append(outputs, "Creating Python virtual environment") + ios.PrintDebug(ctx, "Creating Python virtual environment") if err := createVirtualEnvironment(ctx, projectDirPath); err != nil { outputs = append(outputs, fmt.Sprintf("Error creating virtual environment: %s", err)) return strings.Join(outputs, "\n"), err From b0f0050dd998e0ad10a37d84724734f511833a15 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 20 Feb 2026 14:20:46 -0800 Subject: [PATCH 08/12] chore(python): move dependency install messages to debug output --- internal/runtime/python/python.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/runtime/python/python.go b/internal/runtime/python/python.go index 5233b04a..bf95c1ab 100644 --- a/internal/runtime/python/python.go +++ b/internal/runtime/python/python.go @@ -320,7 +320,7 @@ func (p *Python) InstallProjectDependencies(ctx context.Context, projectDirPath // and its declared dependencies. Then requirements.txt is installed second so its version // pins take precedence, as it typically serves as the lockfile. if hasPyProjectToml { - outputs = append(outputs, "Installing dependencies from pyproject.toml") + ios.PrintDebug(ctx, "Installing dependencies from pyproject.toml") pipOutput, err := runPipInstall(ctx, venvPath, projectDirPath, "-e", ".") if err != nil { errs = append(errs, err) @@ -331,7 +331,7 @@ func (p *Python) InstallProjectDependencies(ctx context.Context, projectDirPath } if hasRequirementsTxt { - outputs = append(outputs, "Installing dependencies from requirements.txt") + ios.PrintDebug(ctx, "Installing dependencies from requirements.txt") pipOutput, err := runPipInstall(ctx, venvPath, projectDirPath, "-r", "requirements.txt") if err != nil { errs = append(errs, err) From e641b20ab5ba658730e1935167e8aa510bbb6ba5 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 20 Feb 2026 14:24:42 -0800 Subject: [PATCH 09/12] chore(python): shorten install success messages and highlight filenames --- internal/runtime/python/python.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/runtime/python/python.go b/internal/runtime/python/python.go index bf95c1ab..eeee249c 100644 --- a/internal/runtime/python/python.go +++ b/internal/runtime/python/python.go @@ -326,7 +326,7 @@ func (p *Python) InstallProjectDependencies(ctx context.Context, projectDirPath errs = append(errs, err) outputs = append(outputs, fmt.Sprintf("Error installing from pyproject.toml: %s\n%s", err, pipOutput)) } else { - outputs = append(outputs, "Successfully installed dependencies from pyproject.toml") + outputs = append(outputs, fmt.Sprintf("Installed dependencies from %s", style.Highlight("pyproject.toml"))) } } @@ -337,7 +337,7 @@ func (p *Python) InstallProjectDependencies(ctx context.Context, projectDirPath errs = append(errs, err) outputs = append(outputs, fmt.Sprintf("Error installing from requirements.txt: %s\n%s", err, pipOutput)) } else { - outputs = append(outputs, "Successfully installed dependencies from requirements.txt") + outputs = append(outputs, fmt.Sprintf("Installed dependencies from %s", style.Highlight("requirements.txt"))) } } From 4324103cfa433e855ac5677d156b0a4b9a814a66 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 20 Feb 2026 15:27:20 -0800 Subject: [PATCH 10/12] fix(python): improve error handling for dependency file updates Clarify error messages in installPyProjectToml to include "updating pyproject.toml" context, and remove early return on file update errors so pip install is attempted regardless. --- internal/runtime/python/python.go | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/internal/runtime/python/python.go b/internal/runtime/python/python.go index eeee249c..b28ff983 100644 --- a/internal/runtime/python/python.go +++ b/internal/runtime/python/python.go @@ -190,18 +190,18 @@ func installPyProjectToml(fs afero.Fs, projectDirPath string) (output string, er projectSection, exists := config["project"] if !exists { err := fmt.Errorf("pyproject.toml missing project section") - return fmt.Sprintf("Error: %s", err), err + return fmt.Sprintf("Error updating pyproject.toml: %s", err), err } projectMap, ok := projectSection.(map[string]interface{}) if !ok { err := fmt.Errorf("pyproject.toml project section is not a valid format") - return fmt.Sprintf("Error: %s", err), err + return fmt.Sprintf("Error updating pyproject.toml: %s", err), err } if _, exists := projectMap["dependencies"]; !exists { err := fmt.Errorf("pyproject.toml missing dependencies array") - return fmt.Sprintf("Error: %s", err), err + return fmt.Sprintf("Error updating pyproject.toml: %s", err), err } // Use string manipulation to add the dependency while preserving formatting. @@ -213,7 +213,7 @@ func installPyProjectToml(fs afero.Fs, projectDirPath string) (output string, er if len(matches) == 0 { err := fmt.Errorf("pyproject.toml missing dependencies array") - return fmt.Sprintf("Error: %s", err), err + return fmt.Sprintf("Error updating pyproject.toml: %s", err), err } prefix := matches[1] // "...dependencies = [" @@ -310,11 +310,6 @@ func (p *Python) InstallProjectDependencies(ctx context.Context, projectDirPath } } - // Return early if we had errors updating dependency files - if len(errs) > 0 { - return strings.Join(outputs, "\n"), errs[0] - } - // Install dependencies using pip // When both files exist, pyproject.toml is installed first to set up the project package // and its declared dependencies. Then requirements.txt is installed second so its version From 3a76ba88efce6142b710121cb1b41e394ae49c1d Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 20 Feb 2026 15:41:17 -0800 Subject: [PATCH 11/12] test: remove skipped test --- internal/runtime/python/python_test.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/internal/runtime/python/python_test.go b/internal/runtime/python/python_test.go index a1d676b4..4d4c262e 100644 --- a/internal/runtime/python/python_test.go +++ b/internal/runtime/python/python_test.go @@ -314,13 +314,7 @@ func Test_venvExists(t *testing.T) { } } -// Test_Python_InstallProjectDependencies tests the main function -// NOTE: These tests require Python 3 and pip to be installed for full integration testing. -// The function creates real virtual environments and runs real pip commands. -// Unit tests below focus on testing individual components. func Test_Python_InstallProjectDependencies(t *testing.T) { - t.Skip("Skipping integration tests - requires Python 3, pip, and real file system") - tests := map[string]struct { existingFiles map[string]string expectedFiles map[string]string From 180d49947c89b9fd961705ff661fd08472fb1368 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 20 Feb 2026 16:24:27 -0800 Subject: [PATCH 12/12] fix(python): use HookExecutor for venv and pip commands to fix tests Route createVirtualEnvironment and runPipInstall through HookExecutor instead of os/exec, matching the npm runtime pattern. This makes the functions testable with the mock filesystem. Also add missing AddDefaultMocks call, remove stale test cases, and fix assertion strings. --- internal/runtime/python/python.go | 53 ++++++++++++++++---------- internal/runtime/python/python_test.go | 40 ++----------------- 2 files changed, 36 insertions(+), 57 deletions(-) diff --git a/internal/runtime/python/python.go b/internal/runtime/python/python.go index b28ff983..8d5ad6aa 100644 --- a/internal/runtime/python/python.go +++ b/internal/runtime/python/python.go @@ -15,10 +15,10 @@ package python import ( + "bytes" "context" _ "embed" "fmt" - "os/exec" "path/filepath" "regexp" "runtime" @@ -94,12 +94,20 @@ func venvExists(fs afero.Fs, venvPath string) bool { } // createVirtualEnvironment creates a Python virtual environment -func createVirtualEnvironment(ctx context.Context, projectDirPath string) error { - cmd := exec.CommandContext(ctx, getPythonExecutable(), "-m", "venv", ".venv") - cmd.Dir = projectDirPath - output, err := cmd.CombinedOutput() +func createVirtualEnvironment(ctx context.Context, projectDirPath string, hookExecutor hooks.HookExecutor) error { + hookScript := hooks.HookScript{ + Name: "CreateVirtualEnvironment", + Command: fmt.Sprintf("%s -m venv .venv", getPythonExecutable()), + } + stdout := bytes.Buffer{} + hookExecOpts := hooks.HookExecOpts{ + Hook: hookScript, + Stdout: &stdout, + Directory: projectDirPath, + } + _, err := hookExecutor.Execute(ctx, hookExecOpts) if err != nil { - return fmt.Errorf("failed to create virtual environment: %w\nOutput: %s", err, string(output)) + return fmt.Errorf("failed to create virtual environment: %w\nOutput: %s", err, stdout.String()) } return nil } @@ -108,20 +116,25 @@ func createVirtualEnvironment(ctx context.Context, projectDirPath string) error // The venv does not need to be activated because pip is invoked by its full // path inside the venv, which ensures packages are installed into the venv's // site-packages directory. -func runPipInstall(ctx context.Context, venvPath string, projectDirPath string, args ...string) (string, error) { +func runPipInstall(ctx context.Context, venvPath string, projectDirPath string, hookExecutor hooks.HookExecutor, args ...string) (string, error) { pipPath := getPipExecutable(venvPath) - - // Build command: pip install [args] - cmdArgs := append([]string{"install"}, args...) - cmd := exec.CommandContext(ctx, pipPath, cmdArgs...) - cmd.Dir = projectDirPath - - output, err := cmd.CombinedOutput() + cmdArgs := append([]string{pipPath, "install"}, args...) + hookScript := hooks.HookScript{ + Name: "InstallProjectDependencies", + Command: strings.Join(cmdArgs, " "), + } + stdout := bytes.Buffer{} + hookExecOpts := hooks.HookExecOpts{ + Hook: hookScript, + Stdout: &stdout, + Directory: projectDirPath, + } + _, err := hookExecutor.Execute(ctx, hookExecOpts) + output := stdout.String() if err != nil { - return string(output), fmt.Errorf("pip install failed: %w", err) + return output, fmt.Errorf("pip install failed: %w", err) } - - return string(output), nil + return output, nil } // installRequirementsTxt handles adding slack-cli-hooks to requirements.txt @@ -283,7 +296,7 @@ func (p *Python) InstallProjectDependencies(ctx context.Context, projectDirPath // Create virtual environment if it doesn't exist if !venvExists(fs, venvPath) { ios.PrintDebug(ctx, "Creating Python virtual environment") - if err := createVirtualEnvironment(ctx, projectDirPath); err != nil { + if err := createVirtualEnvironment(ctx, projectDirPath, hookExecutor); err != nil { outputs = append(outputs, fmt.Sprintf("Error creating virtual environment: %s", err)) return strings.Join(outputs, "\n"), err } @@ -316,7 +329,7 @@ func (p *Python) InstallProjectDependencies(ctx context.Context, projectDirPath // pins take precedence, as it typically serves as the lockfile. if hasPyProjectToml { ios.PrintDebug(ctx, "Installing dependencies from pyproject.toml") - pipOutput, err := runPipInstall(ctx, venvPath, projectDirPath, "-e", ".") + pipOutput, err := runPipInstall(ctx, venvPath, projectDirPath, hookExecutor, "-e", ".") if err != nil { errs = append(errs, err) outputs = append(outputs, fmt.Sprintf("Error installing from pyproject.toml: %s\n%s", err, pipOutput)) @@ -327,7 +340,7 @@ func (p *Python) InstallProjectDependencies(ctx context.Context, projectDirPath if hasRequirementsTxt { ios.PrintDebug(ctx, "Installing dependencies from requirements.txt") - pipOutput, err := runPipInstall(ctx, venvPath, projectDirPath, "-r", "requirements.txt") + pipOutput, err := runPipInstall(ctx, venvPath, projectDirPath, hookExecutor, "-r", "requirements.txt") if err != nil { errs = append(errs, err) outputs = append(outputs, fmt.Sprintf("Error installing from requirements.txt: %s\n%s", err, pipOutput)) diff --git a/internal/runtime/python/python_test.go b/internal/runtime/python/python_test.go index 4d4c262e..f16af53c 100644 --- a/internal/runtime/python/python_test.go +++ b/internal/runtime/python/python_test.go @@ -397,41 +397,6 @@ func Test_Python_InstallProjectDependencies(t *testing.T) { expectedOutputs: []string{"Updated"}, expectedError: false, }, - "Should output help text because installing project dependencies is unsupported": { - existingFiles: map[string]string{ - "requirements.txt": "slack-cli-hooks\npytest==8.3.2\nruff==0.7.2", - }, - expectedOutputs: []string{"Manually setup a Python virtual environment"}, - expectedError: false, - }, - "Should output pip install -r requirements.txt when only requirements.txt exists": { - existingFiles: map[string]string{ - "requirements.txt": "slack-cli-hooks\npytest==8.3.2", - }, - expectedOutputs: []string{"pip install -r requirements.txt"}, - notExpectedOutputs: []string{"pip install -e ."}, - expectedError: false, - }, - "Should output pip install -e . when only pyproject.toml exists": { - existingFiles: map[string]string{ - "pyproject.toml": `[project] -name = "my-app" -dependencies = ["slack-cli-hooks<1.0.0"]`, - }, - expectedOutputs: []string{"pip install -e ."}, - notExpectedOutputs: []string{"pip install -r requirements.txt"}, - expectedError: false, - }, - "Should output both install commands when both files exist": { - existingFiles: map[string]string{ - "requirements.txt": "slack-cli-hooks\npytest==8.3.2", - "pyproject.toml": `[project] -name = "my-app" -dependencies = ["slack-cli-hooks<1.0.0"]`, - }, - expectedOutputs: []string{"pip install -r requirements.txt", "pip install -e ."}, - expectedError: false, - }, "Error when neither requirements.txt nor pyproject.toml exists": { existingFiles: map[string]string{ "main.py": "# some python code", @@ -525,7 +490,7 @@ dependencies = [ "pyproject.toml": `[project] name = "my-app"`, }, - expectedOutputs: []string{"Error: pyproject.toml missing dependencies array"}, + expectedOutputs: []string{"Error updating pyproject.toml: pyproject.toml missing dependencies array"}, expectedError: true, }, "Error when pyproject.toml has no [project] section": { @@ -533,7 +498,7 @@ name = "my-app"`, "pyproject.toml": `[tool.black] line-length = 88`, }, - expectedOutputs: []string{"Error: pyproject.toml missing project section"}, + expectedOutputs: []string{"Error updating pyproject.toml: pyproject.toml missing project section"}, expectedError: true, }, "Error when pyproject.toml is invalid TOML": { @@ -554,6 +519,7 @@ name = "broken`, os.AddDefaultMocks() cfg := config.NewConfig(fs, os) ios := iostreams.NewIOStreamsMock(cfg, fs, os) + ios.AddDefaultMocks() mockHookExecutor := &hooks.MockHookExecutor{} mockHookExecutor.On("Execute", mock.Anything, mock.Anything).Return("text output", nil)