@@ -23,6 +23,7 @@ import (
2323 "runtime"
2424 "strings"
2525
26+ "github.com/pelletier/go-toml/v2"
2627 "github.com/slackapi/slack-cli/internal/hooks"
2728 "github.com/slackapi/slack-cli/internal/iostreams"
2829 "github.com/slackapi/slack-cli/internal/shared/types"
@@ -61,12 +62,154 @@ func (p *Python) IgnoreDirectories() []string {
6162 return []string {}
6263}
6364
65+ // installRequirementsTxt handles adding slack-cli-hooks to requirements.txt
66+ func installRequirementsTxt (fs afero.Fs , projectDirPath string ) (output string , err error ) {
67+ requirementsFilePath := filepath .Join (projectDirPath , "requirements.txt" )
68+
69+ file , err := afero .ReadFile (fs , requirementsFilePath )
70+ if err != nil {
71+ return fmt .Sprintf ("Error reading requirements.txt: %s" , err ), err
72+ }
73+
74+ fileData := string (file )
75+
76+ // Skip when slack-cli-hooks is already declared in requirements.txt
77+ if strings .Contains (fileData , slackCLIHooksPackageName ) {
78+ return fmt .Sprintf ("Found requirements.txt with %s" , style .Highlight (slackCLIHooksPackageName )), nil
79+ }
80+
81+ // Add slack-cli-hooks to requirements.txt
82+ //
83+ // Regex finds all lines that match "slack-bolt" including optional version specifier (e.g. slack-bolt==1.21.2)
84+ re := regexp .MustCompile (fmt .Sprintf (`(%s.*)` , slackBoltPackageName ))
85+ matches := re .FindAllString (fileData , - 1 )
86+
87+ if len (matches ) > 0 {
88+ // Inserted under the slack-bolt dependency
89+ fileData = re .ReplaceAllString (fileData , fmt .Sprintf ("$1\n %s" , slackCLIHooksPackageSpecifier ))
90+ } else {
91+ // Insert at bottom of file
92+ fileData = fmt .Sprintf ("%s\n %s" , strings .TrimSpace (fileData ), slackCLIHooksPackageSpecifier )
93+ }
94+
95+ // Save requirements.txt
96+ err = afero .WriteFile (fs , requirementsFilePath , []byte (fileData ), 0644 )
97+ if err != nil {
98+ return fmt .Sprintf ("Error updating requirements.txt: %s" , err ), err
99+ }
100+
101+ return fmt .Sprintf ("Updated requirements.txt with %s" , style .Highlight (slackCLIHooksPackageSpecifier )), nil
102+ }
103+
104+ // installPyProjectToml handles adding slack-cli-hooks to pyproject.toml
105+ func installPyProjectToml (fs afero.Fs , projectDirPath string ) (output string , err error ) {
106+ pyProjectFilePath := filepath .Join (projectDirPath , "pyproject.toml" )
107+
108+ file , err := afero .ReadFile (fs , pyProjectFilePath )
109+ if err != nil {
110+ return fmt .Sprintf ("Error reading pyproject.toml: %s" , err ), err
111+ }
112+
113+ fileData := string (file )
114+
115+ // Check if slack-cli-hooks is already declared
116+ if strings .Contains (fileData , slackCLIHooksPackageName ) {
117+ return fmt .Sprintf ("Found pyproject.toml with %s" , style .Highlight (slackCLIHooksPackageName )), nil
118+ }
119+
120+ // Parse only to validate the file structure
121+ var config map [string ]interface {}
122+ err = toml .Unmarshal (file , & config )
123+ if err != nil {
124+ return fmt .Sprintf ("Error parsing pyproject.toml: %s" , err ), err
125+ }
126+
127+ // Verify `project` section and `project.dependencies` array exist
128+ projectSection , exists := config ["project" ]
129+ if ! exists {
130+ err := fmt .Errorf ("pyproject.toml missing project section" )
131+ return fmt .Sprintf ("Error: %s" , err ), err
132+ }
133+
134+ projectMap , ok := projectSection .(map [string ]interface {})
135+ if ! ok {
136+ err := fmt .Errorf ("pyproject.toml project section is not a valid format" )
137+ return fmt .Sprintf ("Error: %s" , err ), err
138+ }
139+
140+ if _ , exists := projectMap ["dependencies" ]; ! exists {
141+ err := fmt .Errorf ("pyproject.toml missing dependencies array" )
142+ return fmt .Sprintf ("Error: %s" , err ), err
143+ }
144+
145+ // Use string manipulation to add the dependency while preserving formatting.
146+ // This regex matches the dependencies array and its contents, handling both single-line and multi-line formats.
147+ // Note: This regex may not correctly handle commented-out dependencies arrays or nested brackets in string values.
148+ // These edge cases are uncommon in practice and the TOML validation above will catch malformed files.
149+ dependenciesRegex := regexp .MustCompile (`(?s)(dependencies\s*=\s*\[)([^\]]*?)(\])` )
150+ matches := dependenciesRegex .FindStringSubmatch (fileData )
151+
152+ if len (matches ) == 0 {
153+ err := fmt .Errorf ("pyproject.toml missing dependencies array" )
154+ return fmt .Sprintf ("Error: %s" , err ), err
155+ }
156+
157+ prefix := matches [1 ] // "...dependencies = ["
158+ content := matches [2 ] // array contents
159+ suffix := matches [3 ] // "]..."
160+
161+ // Always append slack-cli-hooks at the end of the dependencies array.
162+ // Formatting:
163+ // - Multi-line arrays get a trailing comma to match Python/TOML conventions
164+ // and make future additions cleaner.
165+ // - Single-line arrays omit the trailing comma for a compact appearance,
166+ // which is the typical style for short dependency lists.
167+ var newContent string
168+ content = strings .TrimRight (content , " \t \n " )
169+ if ! strings .HasSuffix (content , "," ) {
170+ content += ","
171+ }
172+ if strings .Contains (content , "\n " ) {
173+ // Multi-line format: append with proper indentation and trailing comma
174+ newContent = content + "\n " + ` "` + slackCLIHooksPackageSpecifier + `",` + "\n "
175+ } else {
176+ // Single-line format: append inline without trailing comma
177+ newContent = content + ` "` + slackCLIHooksPackageSpecifier + `"`
178+ }
179+
180+ // Replace the dependencies array content
181+ fileData = dependenciesRegex .ReplaceAllString (fileData , prefix + newContent + suffix )
182+
183+ // Save pyproject.toml
184+ err = afero .WriteFile (fs , pyProjectFilePath , []byte (fileData ), 0644 )
185+ if err != nil {
186+ return fmt .Sprintf ("Error updating pyproject.toml: %s" , err ), err
187+ }
188+
189+ return fmt .Sprintf ("Updated pyproject.toml with %s" , style .Highlight (slackCLIHooksPackageSpecifier )), nil
190+ }
191+
64192// InstallProjectDependencies is unsupported by Python because a virtual environment is required before installing the project dependencies.
65193// TODO(@mbrooks) - should we confirm that the project is using Bolt Python?
66194func (p * Python ) InstallProjectDependencies (ctx context.Context , projectDirPath string , hookExecutor hooks.HookExecutor , ios iostreams.IOStreamer , fs afero.Fs , os types.Os ) (output string , err error ) {
67195 var outputs []string
68196 var errs []error
69197
198+ // Detect which dependency file(s) exist
199+ requirementsFilePath := filepath .Join (projectDirPath , "requirements.txt" )
200+ pyProjectFilePath := filepath .Join (projectDirPath , "pyproject.toml" )
201+
202+ hasRequirementsTxt := false
203+ hasPyProjectToml := false
204+
205+ if _ , err := fs .Stat (requirementsFilePath ); err == nil {
206+ hasRequirementsTxt = true
207+ }
208+
209+ if _ , err := fs .Stat (pyProjectFilePath ); err == nil {
210+ hasPyProjectToml = true
211+ }
212+
70213 // Defer a function to transform the return values
71214 defer func () {
72215 // Manual steps to setup virtual environment and install dependencies
@@ -84,7 +227,15 @@ func (p *Python) InstallProjectDependencies(ctx context.Context, projectDirPath
84227 }
85228 outputs = append (outputs , fmt .Sprintf (" Create virtual environment: %s" , style .CommandText ("python3 -m venv .venv" )))
86229 outputs = append (outputs , fmt .Sprintf (" Activate virtual environment: %s" , style .CommandText (activateVirtualEnv )))
87- outputs = append (outputs , fmt .Sprintf (" Install project dependencies: %s" , style .CommandText ("pip install -r requirements.txt" )))
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+ }
238+
88239 outputs = append (outputs , fmt .Sprintf (" Learn more: %s" , style .Underline ("https://docs.python.org/3/tutorial/venv.html" )))
89240
90241 // Get first error or nil
@@ -98,45 +249,29 @@ func (p *Python) InstallProjectDependencies(ctx context.Context, projectDirPath
98249 err = firstErr
99250 }()
100251
101- // Read requirements.txt
102- var requirementsFilePath = filepath .Join (projectDirPath , "requirements.txt" )
103-
104- file , err := afero .ReadFile (fs , requirementsFilePath )
105- if err != nil {
106- errs = append (errs , err )
107- outputs = append (outputs , fmt .Sprintf ("Error reading requirements.txt: %s" , err ))
108- return
109- }
110-
111- fileData := string (file )
112-
113- // Skip when slack-cli-hooks is already declared in requirements.txt
114- if strings .Contains (fileData , slackCLIHooksPackageName ) {
115- outputs = append (outputs , fmt .Sprintf ("Found requirements.txt with %s" , style .Highlight (slackCLIHooksPackageName )))
116- return
252+ // Handle requirements.txt if it exists
253+ if hasRequirementsTxt {
254+ output , err := installRequirementsTxt (fs , projectDirPath )
255+ outputs = append (outputs , output )
256+ if err != nil {
257+ errs = append (errs , err )
258+ }
117259 }
118260
119- // Add slack-cli-hooks to requirements.txt
120- //
121- // Regex finds all lines that match "slack-bolt" including optional version specifier (e.g. slack-bolt==1.21.2)
122- re := regexp .MustCompile (fmt .Sprintf (`(%s.*)` , slackBoltPackageName ))
123- matches := re .FindAllString (fileData , - 1 )
124-
125- if len (matches ) > 0 {
126- // Inserted under the slack-bolt dependency
127- fileData = re .ReplaceAllString (fileData , fmt .Sprintf ("$1\n %s" , slackCLIHooksPackageSpecifier ))
128- } else {
129- // Insert at bottom of file
130- fileData = fmt .Sprintf ("%s\n %s" , strings .TrimSpace (fileData ), slackCLIHooksPackageSpecifier )
261+ // Handle pyproject.toml if it exists
262+ if hasPyProjectToml {
263+ output , err := installPyProjectToml (fs , projectDirPath )
264+ outputs = append (outputs , output )
265+ if err != nil {
266+ errs = append (errs , err )
267+ }
131268 }
132269
133- // Save requirements.txt
134- err = afero . WriteFile ( fs , requirementsFilePath , [] byte ( fileData ), 0644 )
135- if err != nil {
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)" )
136273 errs = append (errs , err )
137- outputs = append (outputs , fmt .Sprintf ("Error updating requirements.txt: %s" , err ))
138- } else {
139- outputs = append (outputs , fmt .Sprintf ("Updated requirements.txt with %s" , style .Highlight (slackCLIHooksPackageSpecifier )))
274+ outputs = append (outputs , fmt .Sprintf ("Error: %s" , err ))
140275 }
141276
142277 return
@@ -174,12 +309,17 @@ func IsRuntimeForProject(ctx context.Context, fs afero.Fs, dirPath string, sdkCo
174309 return true
175310 }
176311
177- // Python projects must have a requirements.txt in the root dirPath
312+ // Python projects must have a requirements.txt or pyproject.toml in the root dirPath
178313 var requirementsTxtPath = filepath .Join (dirPath , "requirements.txt" )
179314 if _ , err := fs .Stat (requirementsTxtPath ); err == nil {
180315 return true
181316 }
182317
318+ var pyProjectTomlPath = filepath .Join (dirPath , "pyproject.toml" )
319+ if _ , err := fs .Stat (pyProjectTomlPath ); err == nil {
320+ return true
321+ }
322+
183323 return false
184324}
185325
0 commit comments