diff --git a/internal/pythonfinder/finder.go b/internal/pythonfinder/finder.go index 7107c6e..8212769 100644 --- a/internal/pythonfinder/finder.go +++ b/internal/pythonfinder/finder.go @@ -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 @@ -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 @@ -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. @@ -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 } diff --git a/internal/pythonfinder/version.go b/internal/pythonfinder/version.go index b6f7bd5..9da9004 100644 --- a/internal/pythonfinder/version.go +++ b/internal/pythonfinder/version.go @@ -3,6 +3,7 @@ package pythonfinder import ( "fmt" "os/exec" + "regexp" "strings" pep440Version "github.com/aquasecurity/go-pep440-version" @@ -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 + } +} diff --git a/internal/pythonfinder/version_test.go b/internal/pythonfinder/version_test.go index f5a45c7..171e34e 100644 --- a/internal/pythonfinder/version_test.go +++ b/internal/pythonfinder/version_test.go @@ -7,6 +7,8 @@ import ( "reflect" "strings" "testing" + + pep440Version "github.com/aquasecurity/go-pep440-version" ) // Fake exec command helper @@ -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) + } + }) + } +}