diff --git a/cmd/project/init.go b/cmd/project/init.go index 4ffe0089..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: Unsupported", + "- Python: Supported", "", "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..8d5ad6aa 100644 --- a/internal/runtime/python/python.go +++ b/internal/runtime/python/python.go @@ -15,6 +15,7 @@ package python import ( + "bytes" "context" _ "embed" "fmt" @@ -62,6 +63,80 @@ 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") +} + +// 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" { + 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, 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, stdout.String()) + } + return nil +} + +// 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, hookExecutor hooks.HookExecutor, args ...string) (string, error) { + pipPath := getPipExecutable(venvPath) + 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 output, fmt.Errorf("pip install failed: %w", err) + } + return 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") @@ -128,18 +203,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. @@ -151,7 +226,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 = [" @@ -189,8 +264,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 +284,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) { + ios.PrintDebug(ctx, "Creating Python virtual environment") + 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 } - - // 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 +323,38 @@ 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)) + // 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 + // 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, hookExecutor, "-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, fmt.Sprintf("Installed dependencies from %s", style.Highlight("pyproject.toml"))) + } } - return + if hasRequirementsTxt { + ios.PrintDebug(ctx, "Installing dependencies from 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)) + } else { + outputs = append(outputs, fmt.Sprintf("Installed dependencies from %s", style.Highlight("requirements.txt"))) + } + } + + // 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..f16af53c 100644 --- a/internal/runtime/python/python_test.go +++ b/internal/runtime/python/python_test.go @@ -63,6 +63,257 @@ 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_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 + 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) + }) + } +} + func Test_Python_InstallProjectDependencies(t *testing.T) { tests := map[string]struct { existingFiles map[string]string @@ -146,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", @@ -274,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": { @@ -282,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": { @@ -303,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)