1515package python
1616
1717import (
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\n Output: %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
66141func 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.
194268func (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