Skip to content

Commit 3417188

Browse files
mwbrookszimeg
andauthored
feat(python): add support for creating python .venv and installing dependencies (#346)
Co-authored-by: Eden Zimbelman <eden.zimbelman@salesforce.com>
1 parent 44cb5f9 commit 3417188

File tree

3 files changed

+364
-85
lines changed

3 files changed

+364
-85
lines changed

cmd/project/init.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func NewInitCommand(clients *shared.ClientFactory) *cobra.Command {
5252
"Installs your project dependencies when supported:",
5353
"- Deno: Supported",
5454
"- Node.js: Supported",
55-
"- Python: Unsupported",
55+
"- Python: Supported",
5656
"",
5757
"Adds an existing app to your project (optional):",
5858
"- Prompts to add an existing app from app settings",

internal/runtime/python/python.go

Lines changed: 127 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package python
1616

1717
import (
18+
"bytes"
1819
"context"
1920
_ "embed"
2021
"fmt"
@@ -62,6 +63,80 @@ func (p *Python) IgnoreDirectories() []string {
6263
return []string{}
6364
}
6465

66+
// getVenvPath returns the path to the virtual environment directory
67+
func getVenvPath(projectDirPath string) string {
68+
return filepath.Join(projectDirPath, ".venv")
69+
}
70+
71+
// getPythonExecutable returns the Python executable name for the current OS
72+
func getPythonExecutable() string {
73+
if runtime.GOOS == "windows" {
74+
return "python"
75+
}
76+
return "python3"
77+
}
78+
79+
// getPipExecutable returns the path to the pip executable in the virtual environment
80+
func getPipExecutable(venvPath string) string {
81+
if runtime.GOOS == "windows" {
82+
return filepath.Join(venvPath, "Scripts", "pip.exe")
83+
}
84+
return filepath.Join(venvPath, "bin", "pip")
85+
}
86+
87+
// venvExists checks if a virtual environment exists at the given path
88+
func venvExists(fs afero.Fs, venvPath string) bool {
89+
pipPath := getPipExecutable(venvPath)
90+
if _, err := fs.Stat(pipPath); err == nil {
91+
return true
92+
}
93+
return false
94+
}
95+
96+
// createVirtualEnvironment creates a Python virtual environment
97+
func createVirtualEnvironment(ctx context.Context, projectDirPath string, hookExecutor hooks.HookExecutor) error {
98+
hookScript := hooks.HookScript{
99+
Name: "CreateVirtualEnvironment",
100+
Command: fmt.Sprintf("%s -m venv .venv", getPythonExecutable()),
101+
}
102+
stdout := bytes.Buffer{}
103+
hookExecOpts := hooks.HookExecOpts{
104+
Hook: hookScript,
105+
Stdout: &stdout,
106+
Directory: projectDirPath,
107+
}
108+
_, err := hookExecutor.Execute(ctx, hookExecOpts)
109+
if err != nil {
110+
return fmt.Errorf("failed to create virtual environment: %w\nOutput: %s", err, stdout.String())
111+
}
112+
return nil
113+
}
114+
115+
// runPipInstall runs pip install with the given arguments.
116+
// The venv does not need to be activated because pip is invoked by its full
117+
// path inside the venv, which ensures packages are installed into the venv's
118+
// site-packages directory.
119+
func runPipInstall(ctx context.Context, venvPath string, projectDirPath string, hookExecutor hooks.HookExecutor, args ...string) (string, error) {
120+
pipPath := getPipExecutable(venvPath)
121+
cmdArgs := append([]string{pipPath, "install"}, args...)
122+
hookScript := hooks.HookScript{
123+
Name: "InstallProjectDependencies",
124+
Command: strings.Join(cmdArgs, " "),
125+
}
126+
stdout := bytes.Buffer{}
127+
hookExecOpts := hooks.HookExecOpts{
128+
Hook: hookScript,
129+
Stdout: &stdout,
130+
Directory: projectDirPath,
131+
}
132+
_, err := hookExecutor.Execute(ctx, hookExecOpts)
133+
output := stdout.String()
134+
if err != nil {
135+
return output, fmt.Errorf("pip install failed: %w", err)
136+
}
137+
return output, nil
138+
}
139+
65140
// installRequirementsTxt handles adding slack-cli-hooks to requirements.txt
66141
func installRequirementsTxt(fs afero.Fs, projectDirPath string) (output string, err error) {
67142
requirementsFilePath := filepath.Join(projectDirPath, "requirements.txt")
@@ -128,18 +203,18 @@ func installPyProjectToml(fs afero.Fs, projectDirPath string) (output string, er
128203
projectSection, exists := config["project"]
129204
if !exists {
130205
err := fmt.Errorf("pyproject.toml missing project section")
131-
return fmt.Sprintf("Error: %s", err), err
206+
return fmt.Sprintf("Error updating pyproject.toml: %s", err), err
132207
}
133208

134209
projectMap, ok := projectSection.(map[string]interface{})
135210
if !ok {
136211
err := fmt.Errorf("pyproject.toml project section is not a valid format")
137-
return fmt.Sprintf("Error: %s", err), err
212+
return fmt.Sprintf("Error updating pyproject.toml: %s", err), err
138213
}
139214

140215
if _, exists := projectMap["dependencies"]; !exists {
141216
err := fmt.Errorf("pyproject.toml missing dependencies array")
142-
return fmt.Sprintf("Error: %s", err), err
217+
return fmt.Sprintf("Error updating pyproject.toml: %s", err), err
143218
}
144219

145220
// Use string manipulation to add the dependency while preserving formatting.
@@ -151,7 +226,7 @@ func installPyProjectToml(fs afero.Fs, projectDirPath string) (output string, er
151226

152227
if len(matches) == 0 {
153228
err := fmt.Errorf("pyproject.toml missing dependencies array")
154-
return fmt.Sprintf("Error: %s", err), err
229+
return fmt.Sprintf("Error updating pyproject.toml: %s", err), err
155230
}
156231

157232
prefix := matches[1] // "...dependencies = ["
@@ -189,8 +264,7 @@ func installPyProjectToml(fs afero.Fs, projectDirPath string) (output string, er
189264
return fmt.Sprintf("Updated pyproject.toml with %s", style.Highlight(slackCLIHooksPackageSpecifier)), nil
190265
}
191266

192-
// InstallProjectDependencies is unsupported by Python because a virtual environment is required before installing the project dependencies.
193-
// TODO(@mbrooks) - should we confirm that the project is using Bolt Python?
267+
// InstallProjectDependencies creates a virtual environment and installs project dependencies.
194268
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) {
195269
var outputs []string
196270
var errs []error
@@ -210,44 +284,26 @@ func (p *Python) InstallProjectDependencies(ctx context.Context, projectDirPath
210284
hasPyProjectToml = true
211285
}
212286

213-
// Defer a function to transform the return values
214-
defer func() {
215-
// Manual steps to setup virtual environment and install dependencies
216-
var activateVirtualEnv = "source .venv/bin/activate"
217-
if runtime.GOOS == "windows" {
218-
activateVirtualEnv = `.venv\Scripts\activate`
219-
}
220-
221-
// Get the relative path to the project directory
222-
var projectDirPathRel, _ = getProjectDirRelPath(os, os.GetExecutionDir(), projectDirPath)
223-
224-
outputs = append(outputs, fmt.Sprintf("Manually setup a %s", style.Highlight("Python virtual environment")))
225-
if projectDirPathRel != "." {
226-
outputs = append(outputs, fmt.Sprintf(" Change into the project: %s", style.CommandText(fmt.Sprintf("cd %s%s", filepath.Base(projectDirPathRel), string(filepath.Separator)))))
227-
}
228-
outputs = append(outputs, fmt.Sprintf(" Create virtual environment: %s", style.CommandText("python3 -m venv .venv")))
229-
outputs = append(outputs, fmt.Sprintf(" Activate virtual environment: %s", style.CommandText(activateVirtualEnv)))
230-
231-
// Provide appropriate install command based on which file exists
232-
if hasRequirementsTxt {
233-
outputs = append(outputs, fmt.Sprintf(" Install project dependencies: %s", style.CommandText("pip install -r requirements.txt")))
234-
}
235-
if hasPyProjectToml {
236-
outputs = append(outputs, fmt.Sprintf(" Install project dependencies: %s", style.CommandText("pip install -e .")))
237-
}
287+
// Ensure at least one dependency file exists
288+
if !hasRequirementsTxt && !hasPyProjectToml {
289+
err := fmt.Errorf("no Python dependency file found (requirements.txt or pyproject.toml)")
290+
return fmt.Sprintf("Error: %s", err), err
291+
}
238292

239-
outputs = append(outputs, fmt.Sprintf(" Learn more: %s", style.Underline("https://docs.python.org/3/tutorial/venv.html")))
293+
// Get virtual environment path
294+
venvPath := getVenvPath(projectDirPath)
240295

241-
// Get first error or nil
242-
var firstErr error
243-
if len(errs) > 0 {
244-
firstErr = errs[0]
296+
// Create virtual environment if it doesn't exist
297+
if !venvExists(fs, venvPath) {
298+
ios.PrintDebug(ctx, "Creating Python virtual environment")
299+
if err := createVirtualEnvironment(ctx, projectDirPath, hookExecutor); err != nil {
300+
outputs = append(outputs, fmt.Sprintf("Error creating virtual environment: %s", err))
301+
return strings.Join(outputs, "\n"), err
245302
}
246-
247-
// Update return value
248-
output = strings.Join(outputs, "\n")
249-
err = firstErr
250-
}()
303+
outputs = append(outputs, fmt.Sprintf("Created virtual environment at %s", style.Highlight(".venv")))
304+
} else {
305+
outputs = append(outputs, fmt.Sprintf("Found existing virtual environment at %s", style.Highlight(".venv")))
306+
}
251307

252308
// Handle requirements.txt if it exists
253309
if hasRequirementsTxt {
@@ -267,14 +323,38 @@ func (p *Python) InstallProjectDependencies(ctx context.Context, projectDirPath
267323
}
268324
}
269325

270-
// If neither file exists, return an error
271-
if !hasRequirementsTxt && !hasPyProjectToml {
272-
err := fmt.Errorf("no Python dependency file found (requirements.txt or pyproject.toml)")
273-
errs = append(errs, err)
274-
outputs = append(outputs, fmt.Sprintf("Error: %s", err))
326+
// Install dependencies using pip
327+
// When both files exist, pyproject.toml is installed first to set up the project package
328+
// and its declared dependencies. Then requirements.txt is installed second so its version
329+
// pins take precedence, as it typically serves as the lockfile.
330+
if hasPyProjectToml {
331+
ios.PrintDebug(ctx, "Installing dependencies from pyproject.toml")
332+
pipOutput, err := runPipInstall(ctx, venvPath, projectDirPath, hookExecutor, "-e", ".")
333+
if err != nil {
334+
errs = append(errs, err)
335+
outputs = append(outputs, fmt.Sprintf("Error installing from pyproject.toml: %s\n%s", err, pipOutput))
336+
} else {
337+
outputs = append(outputs, fmt.Sprintf("Installed dependencies from %s", style.Highlight("pyproject.toml")))
338+
}
275339
}
276340

277-
return
341+
if hasRequirementsTxt {
342+
ios.PrintDebug(ctx, "Installing dependencies from requirements.txt")
343+
pipOutput, err := runPipInstall(ctx, venvPath, projectDirPath, hookExecutor, "-r", "requirements.txt")
344+
if err != nil {
345+
errs = append(errs, err)
346+
outputs = append(outputs, fmt.Sprintf("Error installing from requirements.txt: %s\n%s", err, pipOutput))
347+
} else {
348+
outputs = append(outputs, fmt.Sprintf("Installed dependencies from %s", style.Highlight("requirements.txt")))
349+
}
350+
}
351+
352+
// Return result
353+
output = strings.Join(outputs, "\n")
354+
if len(errs) > 0 {
355+
return output, errs[0]
356+
}
357+
return output, nil
278358
}
279359

280360
// Name prints the name of the runtime

0 commit comments

Comments
 (0)