diff --git a/internal/runtime/python/pip.go b/internal/runtime/python/pip.go new file mode 100644 index 00000000..e422c1d9 --- /dev/null +++ b/internal/runtime/python/pip.go @@ -0,0 +1,47 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package python + +import ( + "regexp" + "strings" +) + +// packageNameRegex matches text shaped like a Python package name +// +// https://packaging.python.org/en/latest/specifications/name-normalization/#name-format +var packageNameRegex = regexp.MustCompile(`(?i)[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?`) + +// packageNameSeparatorRegex matches runs of "-", "_", and "." which are equivalent in Python package names +var packageNameSeparatorRegex = regexp.MustCompile(`[-_.]+`) + +// normalizePackageName lowercases a Python package name and replaces runs of separators with "-" +// +// https://packaging.python.org/en/latest/specifications/name-normalization/#name-normalization +func normalizePackageName(name string) string { + return packageNameSeparatorRegex.ReplaceAllString(strings.ToLower(name), "-") +} + +// containsPackage returns if the file data names the package with any spelling +// that normalizes to the same name, such as slack_cli_hooks for slack-cli-hooks +func containsPackage(fileData string, pkgName string) bool { + pkgName = normalizePackageName(pkgName) + for _, name := range packageNameRegex.FindAllString(fileData, -1) { + if normalizePackageName(name) == pkgName { + return true + } + } + return false +} diff --git a/internal/runtime/python/pip_test.go b/internal/runtime/python/pip_test.go new file mode 100644 index 00000000..2b8e7c2b --- /dev/null +++ b/internal/runtime/python/pip_test.go @@ -0,0 +1,50 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package python + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_containsPackage(t *testing.T) { + tests := map[string]struct { + fileData string + expected bool + }{ + "Matches the normalized name": { + fileData: "slack-cli-hooks<1.0.0", + expected: true, + }, + "Matches dots and mixed case": { + fileData: "Slack.CLI-Hooks", + expected: true, + }, + "Matches underscores with extras and markers": { + fileData: `slack_cli_hooks[x]>=0.3; python_version>"3.9"`, + expected: true, + }, + "Skips a longer package name": { + fileData: "slack-cli-hooks-example==1.0.0", + expected: false, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + require.Equal(t, tc.expected, containsPackage(tc.fileData, "slack-cli-hooks")) + }) + } +} diff --git a/internal/runtime/python/python.go b/internal/runtime/python/python.go index 1d3e5a22..bfcc973b 100644 --- a/internal/runtime/python/python.go +++ b/internal/runtime/python/python.go @@ -188,7 +188,7 @@ func installRequirementsTxt(fs afero.Fs, projectDirPath string) (output string, fileData := string(file) // Skip when slack-cli-hooks is already declared in requirements.txt - if strings.Contains(fileData, slackCLIHooksPackageName) { + if containsPackage(fileData, slackCLIHooksPackageName) { return fmt.Sprintf("Found requirements.txt with %s", style.Highlight(slackCLIHooksPackageName)), nil } @@ -228,7 +228,7 @@ func installPyProjectToml(ctx context.Context, ios iostreams.IOStreamer, fs afer fileData := string(file) // Check if slack-cli-hooks is already declared - if strings.Contains(fileData, slackCLIHooksPackageName) { + if containsPackage(fileData, slackCLIHooksPackageName) { return fmt.Sprintf("Found pyproject.toml with %s", style.Highlight(slackCLIHooksPackageName)), nil } diff --git a/internal/runtime/python/python_test.go b/internal/runtime/python/python_test.go index 6bda436d..092fd137 100644 --- a/internal/runtime/python/python_test.go +++ b/internal/runtime/python/python_test.go @@ -150,6 +150,12 @@ func Test_installRequirementsTxt(t *testing.T) { expectedOutput: "Found requirements.txt with", expectedError: false, }, + "Skip when slack_cli_hooks already exists": { + existingContent: "slack_bolt==1.30.0\nslack_cli_hooks==0.3.0\npytest==8.3.2", + expectedContent: "slack_bolt==1.30.0\nslack_cli_hooks==0.3.0\npytest==8.3.2", + expectedOutput: "Found requirements.txt with", + expectedError: false, + }, "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", @@ -210,6 +216,14 @@ dependencies = ["slack-cli-hooks<1.0.0", "pytest==8.3.2"]`, expectedOutput: "Found pyproject.toml with", expectedError: false, }, + "Skip when slack_cli_hooks already exists": { + existingContent: `[project] +name = "my-app" +dependencies = ["slack_bolt==1.30.0", "slack_cli_hooks==0.3.0"]`, + shouldContain: []string{"slack_cli_hooks==0.3.0"}, + expectedOutput: "Found pyproject.toml with", + expectedError: false, + }, "Add after slack-bolt when it exists": { existingContent: `[project] name = "my-app"