Skip to content
This repository has been archived by the owner on May 5, 2024. It is now read-only.

Commit

Permalink
feat: introduce multiple finder strategy (glob)
Browse files Browse the repository at this point in the history
  • Loading branch information
dhruvmanila committed Jun 10, 2023
1 parent 85acf39 commit 6ce29d9
Show file tree
Hide file tree
Showing 3 changed files with 250 additions and 21 deletions.
122 changes: 101 additions & 21 deletions internal/pythonfinder/finder.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,41 @@ import (
)

// finderStrategy is the strategy used by the finder to find Python executables.
//
// This is decided as per the given version.
type finderStrategy int

const (
// findAll finds all Python executables.
findAll finderStrategy = iota
// findOne finds the one Python executable matching the given version.
findOne

// findFirst finds the first Python executable.
findFirst

// findExact finds the Python executable which matches the given version
// exactly.
findExact

// findGlob finds the Python executable which matches the given version
// using glob matching.
findGlob
)

func (s finderStrategy) String() string {
switch s {
case findAll:
return "findAll"
case findFirst:
return "findFirst"
case findExact:
return "findExact"
case findGlob:
return "findMax"
default:
return "unknown"
}
}

// finder is a Python version finder.
type finder struct {
providers []Provider
Expand All @@ -32,13 +58,44 @@ func New() *finder {
// Find returns the Python version which matches the given version, if provided,
// or the first version found by the providers.
//
// This function returns ErrVersionNotFound if no version matching the given
// version is found or there is no Python version installed on the system.
// The strategy used to find the Python version is decided as per the given
// version using the following rules:
// 1. If the given version is empty, find the first Python version.
// 2. If the given version is a final release and not a complete version, find
// the max version. For example, if the given version is 3.11, find the max
// version among all the Python versions which match 3.11.*.
// 3. Otherwise, find the Python version which matches the given version exactly.
//
// A final release is a version which is not a pre-release, post-release, or
// developmental release.
//
// A complete version is a version which has all the version components and
// is a final release. For example, 3.11.2 is a complete version but 3.11 is
// not.
func (f *finder) Find(version string) (*PythonExecutable, error) {
versions, err := f.find(version, findOne)
var strategy finderStrategy
var versionInfo *pep440Version.Version

if version == "" {
strategy = findFirst
} else {
v, err := pep440Version.Parse(version)
if err != nil {
return nil, err
}
versionInfo = &v
if isFinalRelease(versionInfo) && !isCompleteVersion(versionInfo) {
strategy = findGlob
} else {
strategy = findExact
}
}

versions, err := f.find(versionInfo, strategy)
if err != nil {
return nil, err
}

// There is going to be exactly one version in the slice. This is ensured
// by the find() function. Otherwise, it would return ErrVersionNotFound.
return versions[0], nil
Expand All @@ -47,22 +104,24 @@ func (f *finder) Find(version string) (*PythonExecutable, error) {
// FindAll returns all the Python versions available on the system which can be
// found by the providers.
func (f *finder) FindAll() ([]*PythonExecutable, error) {
return f.find("", findAll)
return f.find(nil, findAll)
}

func (f *finder) find(version string, strategy finderStrategy) ([]*PythonExecutable, error) {
var versionInfo *pep440Version.Version
func (f *finder) find(versionInfo *pep440Version.Version, strategy finderStrategy) ([]*PythonExecutable, error) {
var versions []*PythonExecutable
var maxVersion *PythonExecutable

if version != "" {
v, err := pep440Version.Parse(version)
var specifier *pep440Version.Specifiers
if strategy == findGlob {
// If strategy is findMax, versionInfo is guaranteed to be non-nil and
// not a complete version.
s, err := pep440Version.NewSpecifiers("== " + getGlobVersion(versionInfo))
if err != nil {
return nil, err
}
versionInfo = &v
specifier = &s
}

var versions []*PythonExecutable

// seen is a set of Python executables which were already seen by the
// providers. This is used to avoid returning duplicate Python versions.
// This contains the absolute path to the Python executable.
Expand Down Expand Up @@ -93,25 +152,46 @@ ProviderLoop:
return nil, err
}

if versionInfo != nil {
if pythonExecutable.Version.Equal(*versionInfo) {
versions = append(versions, pythonExecutable)
}
} else {
switch strategy {
case findFirst:
versions = append(versions, pythonExecutable)
}

if strategy == findOne && len(versions) == 1 {
break ProviderLoop
case findAll:
versions = append(versions, pythonExecutable)
default:
ordering := pythonExecutable.Version.Compare(*versionInfo)
switch strategy {
case findExact:
if ordering == 0 {
versions = append(versions, pythonExecutable)
break ProviderLoop
}
case findGlob:
if ordering < 0 || !isFinalRelease(pythonExecutable.Version) {
continue
}
if specifier.Check(*pythonExecutable.Version) {
if maxVersion == nil {
maxVersion = pythonExecutable
} else if pythonExecutable.Version.GreaterThan(*maxVersion.Version) {
maxVersion = pythonExecutable
}
}
}
}
}
}

if maxVersion != nil {
versions = append(versions, maxVersion)
}

// This either means that the version provided by the user does not exist,
// or there is no version of Python installed on the system.
if len(versions) == 0 {
return nil, ErrVersionNotFound
}

return versions, nil
}

Expand Down
33 changes: 33 additions & 0 deletions internal/pythonfinder/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package pythonfinder
import (
"fmt"
"os/exec"
"regexp"
"strings"

pep440Version "github.com/aquasecurity/go-pep440-version"
Expand Down Expand Up @@ -57,3 +58,35 @@ func getPythonVersion(executable string) (*pep440Version.Version, error) {

return &versionInfo, nil
}

// isFinalRelease returns true if the given version is a final release, i.e.,
// not a pre-release, post-release or developmental release.
func isFinalRelease(version *pep440Version.Version) bool {
return !version.IsPreRelease() && !version.IsPostRelease() && version.Local() == ""
}

// versionRegex is a regular expression that matches Python version strings
// of the form "X.Y.Z".
var versionRegex = regexp.MustCompile(`^(\d)\.(\d{1,2})\.(\d{1,2})$`)

// isCompleteVersion returns true if the given version is a complete version,
// i.e., it's of the form X.Y.Z.
//
// This function assumes that the given version is a final release.
func isCompleteVersion(version *pep440Version.Version) bool {
return versionRegex.MatchString(version.String())
}

// getGlobVersion returns the glob version for the given version if it's not
// a complete version, otherwise it returns the given version.
//
// This function assumes that the given version is a final release.
func getGlobVersion(version *pep440Version.Version) string {
v := version.String()
switch len(strings.Split(v, ".")) {
case 1, 2:
return fmt.Sprintf("%s.*", v)
default:
return v
}
}
116 changes: 116 additions & 0 deletions internal/pythonfinder/version_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"reflect"
"strings"
"testing"

pep440Version "github.com/aquasecurity/go-pep440-version"
)

// Fake exec command helper
Expand Down Expand Up @@ -67,3 +69,117 @@ func TestHelperProcess(t *testing.T) {

os.Exit(0)
}

func TestIsFinalRelease(t *testing.T) {
tests := []struct {
version string
want bool
}{
{
version: "3.11.0",
want: true,
},
{
version: "3.11.0a1",
want: false,
},
{
version: "3.11.0.post1",
want: false,
},
{
version: "3.11.0.dev1",
want: false,
},
{
version: "3.11.0+local",
want: false,
},
}

for _, tt := range tests {
t.Run(tt.version, func(t *testing.T) {
v := pep440Version.MustParse(tt.version)
got := isFinalRelease(&v)
if got != tt.want {
t.Errorf("isFinalRelease(%q) = %v, want %v", v, got, tt.want)
}
})
}
}

func TestIsCompleteVersion(t *testing.T) {
tests := []struct {
version string
want bool
}{
{
version: "3",
want: false,
},
{
version: "3.11",
want: false,
},
{
version: "3.11.0",
want: true,
},
{
version: "3.11.0a1",
want: false,
},
{
version: "3.11.0.post1",
want: false,
},
{
version: "3.11.0.dev1",
want: false,
},
{
version: "3.11.0+local",
want: false,
},
}

for _, tt := range tests {
t.Run(tt.version, func(t *testing.T) {
v := pep440Version.MustParse(tt.version)
got := isCompleteVersion(&v)
if got != tt.want {
t.Errorf("isCompleteVersion(%q) = %v, want %v", v, got, tt.want)
}
})
}
}

func TestGetGlobVersion(t *testing.T) {
tests := []struct {
version string
want string
}{
{
version: "3",
want: "3.*",
},
{
version: "3.11",
want: "3.11.*",
},
{
version: "3.11.0",
want: "3.11.0",
},
}

for _, tt := range tests {
t.Run(tt.version, func(t *testing.T) {
v := pep440Version.MustParse(tt.version)
got := getGlobVersion(&v)
if got != tt.want {
t.Errorf("getGlobVersion(%q) = %v, want %v", v, got, tt.want)
}
})
}
}

0 comments on commit 6ce29d9

Please sign in to comment.