Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions internal/runtime/python/pip.go
Original file line number Diff line number Diff line change
@@ -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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Matching on normalized names fixes the duplicate slack_cli_hooks entry and also stops counting slack-cli-hooks-extra as declared. Linking the regex and normalizer straight to the packaging spec is a nice touch 📦

for _, name := range packageNameRegex.FindAllString(fileData, -1) {
if normalizePackageName(name) == pkgName {
return true
}
}
return false
}
50 changes: 50 additions & 0 deletions internal/runtime/python/pip_test.go
Original file line number Diff line number Diff line change
@@ -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"))
})
}
}
4 changes: 2 additions & 2 deletions internal/runtime/python/python.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand Down
14 changes: 14 additions & 0 deletions internal/runtime/python/python_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
Loading