Skip to content

Commit 2853d95

Browse files
authored
feat(python): add pyproject.toml support for Python projects (#311)
* feat(python): add pyproject.toml support to init command * feat(python): add support for outputing both install instructions * refactor(python): rename PyprojectToml to PyProjectToml * fix: update error output and add pyproject to spellcheck * fix: error strings should not be capitalized * feat: preserve toml file format * refactor: add comments and test cases * refactor: dry error messages * refactor: clean up error messages to not include toml syntax * refactor: minor comment tweaks * test: remove special inline tests
1 parent 358f1cc commit 2853d95

File tree

6 files changed

+366
-52
lines changed

6 files changed

+366
-52
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"deno",
1616
"iostreams",
1717
"opentracing",
18+
"pyproject",
1819
"safeexec",
1920
"slackcontext",
2021
"slackdeps",

cmd/project/init.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func NewInitCommand(clients *shared.ClientFactory) *cobra.Command {
4747
"Adds the Slack CLI hooks dependency to your project:",
4848
"- Deno: Unsupported",
4949
"- Node.js: Updates package.json",
50-
"- Python: Updates requirements.txt",
50+
"- Python: Updates requirements.txt or pyproject.toml",
5151
"",
5252
"Installs your project dependencies when supported:",
5353
"- Deno: Supported",

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ require (
1515
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
1616
github.com/oliamb/cutter v0.2.2
1717
github.com/opentracing/opentracing-go v1.2.0
18+
github.com/pelletier/go-toml/v2 v2.2.4
1819
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
1920
github.com/pkg/errors v0.9.1
2021
github.com/radovskyb/watcher v1.0.7

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
114114
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
115115
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
116116
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
117+
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
118+
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
117119
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
118120
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
119121
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=

internal/runtime/python/python.go

Lines changed: 176 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -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?
66194
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) {
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

Comments
 (0)