github.com/google/osv-scalibr@v0.4.1/guidedremediation/internal/strategy/relax/relaxer/python.go (about)

     1  // Copyright 2025 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package relaxer
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"slices"
    21  	"strings"
    22  
    23  	"deps.dev/util/resolve"
    24  	"deps.dev/util/semver"
    25  	"github.com/google/osv-scalibr/guidedremediation/upgrade"
    26  	"github.com/google/osv-scalibr/log"
    27  )
    28  
    29  // PythonRelaxer implements RequirementRelaxer for Python.
    30  type PythonRelaxer struct{}
    31  
    32  // Relax attempts to relax an requirement for Python packages.
    33  // It returns the newly relaxed requirement and true if successful.
    34  // If unsuccessful, it returns the original requirement and false.
    35  func (r PythonRelaxer) Relax(ctx context.Context, cl resolve.Client, req resolve.RequirementVersion, config upgrade.Config) (resolve.RequirementVersion, bool) {
    36  	configLevel := config.Get(req.Name)
    37  	if configLevel == upgrade.None {
    38  		return req, false
    39  	}
    40  
    41  	c, err := semver.PyPI.ParseConstraint(req.Version)
    42  	if err != nil {
    43  		// The specified version is not a valid semver constraint
    44  		log.Warnf("Failed to parse Python requirement %s: %v", req.Version, err)
    45  		return req, false
    46  	}
    47  
    48  	// Get all the concrete versions of the package
    49  	allVKs, err := cl.Versions(ctx, req.PackageKey)
    50  	if err != nil {
    51  		return req, false
    52  	}
    53  	var vers []*semver.Version
    54  	for _, vk := range allVKs {
    55  		if vk.VersionType != resolve.Concrete {
    56  			continue
    57  		}
    58  		sv, err := semver.PyPI.Parse(vk.Version)
    59  		if err != nil {
    60  			log.Warnf("Failed to parse Python requirement %s: %v", vk.Version, err)
    61  			continue
    62  		}
    63  		vers = append(vers, sv)
    64  	}
    65  	slices.SortFunc(vers, func(a, b *semver.Version) int {
    66  		return a.Compare(b)
    67  	})
    68  
    69  	// Find the versions on either side of the upper boundary of the requirement
    70  	var lastIdx int   // highest version matching constraint
    71  	nextIdx := -1     // next version outside of range, preferring non-prerelease
    72  	nextIsPre := true // if the next version is a prerelease version
    73  	for lastIdx = len(vers) - 1; lastIdx >= 0; lastIdx-- {
    74  		v := vers[lastIdx]
    75  		if c.MatchVersion(v) { // found the upper bound, stop iterating
    76  			break
    77  		}
    78  
    79  		// Want to prefer non-prerelease versions, so only select one if we haven't seen any non-prerelease versions
    80  		if !v.IsPrerelease() || nextIsPre {
    81  			nextIdx = lastIdx
    82  			nextIsPre = v.IsPrerelease()
    83  		}
    84  	}
    85  
    86  	// Didn't find any higher versions of the package
    87  	if nextIdx == -1 {
    88  		return req, false
    89  	}
    90  
    91  	// No versions match the existing constraint, something is wrong
    92  	if lastIdx == -1 {
    93  		return req, false
    94  	}
    95  
    96  	// Our desired relaxation ordering is
    97  	// 1.2.3 -> 1.2.* -> 1.*.* -> 2.*.* -> 3.*.* -> ...
    98  	cmpVer := vers[lastIdx]
    99  	_, diff := cmpVer.Difference(vers[nextIdx])
   100  	if !configLevel.Allows(diff) {
   101  		return req, false
   102  	}
   103  	if diff == semver.DiffMajor {
   104  		// Want to step only one major version at a time
   105  		// Instead of looking for a difference larger than major,
   106  		// we want to look for a major version bump from the first next version
   107  		cmpVer = vers[nextIdx]
   108  		diff = semver.DiffMinor
   109  	}
   110  
   111  	// Find the highest version with the same difference
   112  	best := vers[nextIdx]
   113  	for i := nextIdx + 1; i < len(vers); i++ {
   114  		_, d := cmpVer.Difference(vers[i])
   115  		// If we've exceeded our allowed upgrade level, stop looking.
   116  		if !configLevel.Allows(d) {
   117  			break
   118  		}
   119  
   120  		// DiffMajor < DiffMinor < DiffPatch < DiffPrerelease
   121  		// So if d is less than the original diff, it represents a larger change
   122  		if d < diff {
   123  			break
   124  		}
   125  		if !vers[i].IsPrerelease() || nextIsPre {
   126  			best = vers[i]
   127  		}
   128  	}
   129  
   130  	// For a pinned version requirement, we pin it to the best version.
   131  	if strings.Contains(req.Version, "==") {
   132  		req.Version = "==" + best.String()
   133  		return req, true
   134  	}
   135  
   136  	// Find the next version that is not pre-release
   137  	var next *semver.Version
   138  	for i := nextIdx; i < len(vers); i++ {
   139  		_, d := cmpVer.Difference(vers[i])
   140  		// If we've exceeded our allowed upgrade level, stop looking.
   141  		if !configLevel.Allows(d) {
   142  			break
   143  		}
   144  		if !vers[i].IsPrerelease() {
   145  			next = vers[i]
   146  			break
   147  		}
   148  	}
   149  	if next == nil {
   150  		// There is no newer version that is not pre-release, so use the latest pre-release.
   151  		next = vers[len(vers)-1]
   152  	}
   153  
   154  	if diff == semver.DiffPatch {
   155  		req.Version = "~=" + next.String()
   156  	} else {
   157  		// For a minor/major diff, we use the next version as the minimum requirement,
   158  		// and use the next major as the upper limit.
   159  		m, _ := next.Major()
   160  		req.Version = fmt.Sprintf(">=%s,<%d.0.0", next.String(), m+1)
   161  	}
   162  	return req, true
   163  }