mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
Script runners (run, start, stop, restart, test, etc.) are not package management commands — remove them so they go through the proxy instead of being skipped.
709 lines
19 KiB
Go
709 lines
19 KiB
Go
package packagemanager
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/spf13/pflag"
|
|
|
|
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
|
)
|
|
|
|
type pypiCommandParser interface {
|
|
ParseCommand(args []string) (*ParsedCommand, error)
|
|
}
|
|
|
|
type PypiPackageManagerConfig struct {
|
|
InstallCommands []string
|
|
NonDownloadCommands []string
|
|
CommandName string
|
|
}
|
|
|
|
func DefaultPipPackageManagerConfig() PypiPackageManagerConfig {
|
|
return PypiPackageManagerConfig{
|
|
InstallCommands: []string{"install"},
|
|
NonDownloadCommands: []string{
|
|
// Removal — no registry download
|
|
"uninstall",
|
|
// Inspection / read-only
|
|
"list", "show", "check", "freeze", "config",
|
|
},
|
|
CommandName: "pip",
|
|
}
|
|
}
|
|
|
|
func DefaultPip3PackageManagerConfig() PypiPackageManagerConfig {
|
|
return PypiPackageManagerConfig{
|
|
InstallCommands: []string{"install"},
|
|
NonDownloadCommands: []string{
|
|
"uninstall",
|
|
"list", "show", "check", "freeze", "config",
|
|
},
|
|
CommandName: "pip3",
|
|
}
|
|
}
|
|
|
|
func DefaultUvPackageManagerConfig() PypiPackageManagerConfig {
|
|
return PypiPackageManagerConfig{
|
|
InstallCommands: []string{"add", "install"},
|
|
// uv uses nested subcommands (e.g., `uv pip list`, `uv tool run`) making it
|
|
// unsafe to classify commands by a single arg scan. Run the proxy for all
|
|
// non-install uv commands to avoid missing coverage.
|
|
NonDownloadCommands: []string{},
|
|
CommandName: "uv",
|
|
}
|
|
}
|
|
|
|
func DefaultPoetryPackageManagerConfig() PypiPackageManagerConfig {
|
|
return PypiPackageManagerConfig{
|
|
InstallCommands: []string{"add"},
|
|
NonDownloadCommands: []string{
|
|
// Removal
|
|
"remove",
|
|
// Inspection / read-only
|
|
"show", "config", "check",
|
|
},
|
|
CommandName: "poetry",
|
|
}
|
|
}
|
|
|
|
type pypiPackageManager struct {
|
|
Config PypiPackageManagerConfig
|
|
parser pypiCommandParser
|
|
}
|
|
|
|
func NewPypiPackageManager(config PypiPackageManagerConfig) (*pypiPackageManager, error) {
|
|
var parser pypiCommandParser
|
|
|
|
switch config.CommandName {
|
|
case "pip":
|
|
parser = NewPipCommandParser(config)
|
|
case "uv":
|
|
parser = NewUVCommandParser(config)
|
|
case "poetry":
|
|
parser = NewPoetryCommandParser(config)
|
|
case "pip3":
|
|
parser = NewPipCommandParser(config)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported package manager: %s", config.CommandName)
|
|
}
|
|
|
|
return &pypiPackageManager{
|
|
Config: config,
|
|
parser: parser,
|
|
}, nil
|
|
}
|
|
|
|
var _ PackageManager = &pypiPackageManager{}
|
|
|
|
func (pypi *pypiPackageManager) Name() string {
|
|
return pypi.Config.CommandName
|
|
}
|
|
|
|
func (pypi *pypiPackageManager) Ecosystem() packagev1.Ecosystem {
|
|
return packagev1.Ecosystem_ECOSYSTEM_PYPI
|
|
}
|
|
|
|
func (pypi *pypiPackageManager) ParseCommand(args []string) (*ParsedCommand, error) {
|
|
return pypi.parser.ParseCommand(args)
|
|
}
|
|
|
|
type pipCommandParser struct {
|
|
config PypiPackageManagerConfig
|
|
}
|
|
|
|
func NewPipCommandParser(config PypiPackageManagerConfig) pypiCommandParser {
|
|
return &pipCommandParser{
|
|
config: config,
|
|
}
|
|
}
|
|
|
|
func (p *pipCommandParser) ParseCommand(args []string) (*ParsedCommand, error) {
|
|
// Remove 'pip' if it's the first argument
|
|
if len(args) > 0 && (args[0] == "pip" || args[0] == "pip3") {
|
|
args = args[1:]
|
|
}
|
|
|
|
command := Command{Exe: p.config.CommandName, Args: args}
|
|
|
|
if len(args) < 1 {
|
|
return &ParsedCommand{Command: command}, nil
|
|
}
|
|
|
|
// Find the install command
|
|
var installCmdIndex = -1
|
|
for idx, arg := range args {
|
|
if slices.Contains(p.config.InstallCommands, arg) {
|
|
installCmdIndex = idx
|
|
break
|
|
}
|
|
}
|
|
|
|
if installCmdIndex == -1 {
|
|
return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: IsFirstNonFlagArgInList(args, p.config.NonDownloadCommands)}, nil
|
|
}
|
|
|
|
// Extract arguments after the install command
|
|
installArgs := args[installCmdIndex+1:]
|
|
|
|
flagSet := pflag.NewFlagSet(p.config.CommandName, pflag.ContinueOnError)
|
|
flagSet.SetOutput(io.Discard)
|
|
flagSet.ParseErrorsAllowlist.UnknownFlags = true
|
|
|
|
// Define flags
|
|
var requirementFiles []string
|
|
flagSet.StringArrayVarP(&requirementFiles, "requirement", "r", nil, "Install from requirement file")
|
|
|
|
// Parse arguments (supports interleaved flags + positional args)
|
|
err := flagSet.Parse(installArgs)
|
|
if err != nil {
|
|
return &ParsedCommand{
|
|
Command: command,
|
|
}, nil
|
|
}
|
|
|
|
// Get remaining arguments (package names)
|
|
packages := flagSet.Args()
|
|
|
|
// Determine if this is a manifest install
|
|
isManifestInstall := len(requirementFiles) > 0
|
|
|
|
// Combine all manifest files
|
|
var allManifestFiles []string
|
|
allManifestFiles = append(allManifestFiles, requirementFiles...)
|
|
|
|
// Process packages
|
|
var installTargets []*PackageInstallTarget
|
|
for _, pkg := range packages {
|
|
packageName, version, extras, err := pypiParsePackageInfo(pkg)
|
|
if err != nil {
|
|
return nil, ErrFailedToParsePackage.Wrap(err)
|
|
}
|
|
|
|
isExplicit := version != ""
|
|
|
|
version, err = pypiGetMatchingVersion(packageName, version)
|
|
if err != nil {
|
|
return nil, ErrFailedToResolveVersion.Wrap(err)
|
|
}
|
|
|
|
installTargets = append(installTargets, &PackageInstallTarget{
|
|
PackageVersion: &packagev1.PackageVersion{
|
|
Package: &packagev1.Package{
|
|
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_PYPI,
|
|
Name: packageName,
|
|
},
|
|
Version: version,
|
|
},
|
|
Extras: extras,
|
|
IsExplicitVersion: isExplicit,
|
|
})
|
|
}
|
|
|
|
return &ParsedCommand{
|
|
Command: command,
|
|
InstallTargets: installTargets,
|
|
IsManifestInstall: isManifestInstall,
|
|
ManifestFiles: allManifestFiles,
|
|
}, nil
|
|
}
|
|
|
|
type uvCommandParser struct {
|
|
config PypiPackageManagerConfig
|
|
}
|
|
|
|
func NewUVCommandParser(config PypiPackageManagerConfig) pypiCommandParser {
|
|
return &uvCommandParser{
|
|
config: config,
|
|
}
|
|
}
|
|
|
|
func (u *uvCommandParser) ParseCommand(args []string) (*ParsedCommand, error) {
|
|
// Remove 'uv' if it's the first argument
|
|
if len(args) > 0 && args[0] == "uv" {
|
|
args = args[1:]
|
|
}
|
|
|
|
command := Command{Exe: u.config.CommandName, Args: args}
|
|
if len(args) < 1 {
|
|
return &ParsedCommand{Command: command}, nil
|
|
}
|
|
|
|
// Handle uv sync command (installs from uv.lock)
|
|
if args[0] == "sync" {
|
|
return &ParsedCommand{
|
|
Command: command,
|
|
InstallTargets: nil,
|
|
IsManifestInstall: true,
|
|
ManifestFiles: []string{"uv.lock"},
|
|
}, nil
|
|
}
|
|
|
|
// Handles pip sync command (installs from requirements.txt style files)
|
|
if len(args) >= 3 && args[0] == "pip" && args[1] == "sync" {
|
|
manifestFile := args[2]
|
|
|
|
return &ParsedCommand{
|
|
Command: command,
|
|
InstallTargets: nil,
|
|
IsManifestInstall: true,
|
|
ManifestFiles: []string{manifestFile},
|
|
}, nil
|
|
}
|
|
|
|
// Find the install command position
|
|
var installCmdIndex = -1
|
|
for idx, arg := range args {
|
|
if slices.Contains(u.config.InstallCommands, arg) {
|
|
installCmdIndex = idx
|
|
break
|
|
}
|
|
}
|
|
|
|
if installCmdIndex == -1 {
|
|
for _, arg := range args {
|
|
if slices.Contains(u.config.NonDownloadCommands, arg) {
|
|
return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: true}, nil
|
|
}
|
|
}
|
|
|
|
return &ParsedCommand{Command: command}, nil
|
|
}
|
|
|
|
// Extract arguments after the install command
|
|
installArgs := args[installCmdIndex+1:]
|
|
|
|
// Set up flag parsing
|
|
flagSet := pflag.NewFlagSet("uv", pflag.ContinueOnError)
|
|
flagSet.SetOutput(io.Discard)
|
|
flagSet.ParseErrorsAllowlist.UnknownFlags = true
|
|
|
|
var manifestFiles []string
|
|
|
|
flagSet.StringArrayVarP(&manifestFiles, "requirement", "r", nil, "Install from requirement file")
|
|
|
|
err := flagSet.Parse(installArgs)
|
|
if err != nil {
|
|
return &ParsedCommand{Command: command}, nil
|
|
}
|
|
|
|
packages := flagSet.Args()
|
|
|
|
// Determine if this is a manifest install
|
|
isManifestInstall := len(manifestFiles) > 0
|
|
|
|
var installTargets []*PackageInstallTarget
|
|
for _, pkg := range packages {
|
|
packageName, version, extras, err := pypiParsePackageInfo(pkg)
|
|
if err != nil {
|
|
return nil, ErrFailedToParsePackage.Wrap(err)
|
|
}
|
|
|
|
isExplicit := version != ""
|
|
|
|
version, err = pypiGetMatchingVersion(packageName, version)
|
|
if err != nil {
|
|
return nil, ErrFailedToResolveVersion.Wrap(err)
|
|
}
|
|
|
|
installTargets = append(installTargets, &PackageInstallTarget{
|
|
PackageVersion: &packagev1.PackageVersion{
|
|
Package: &packagev1.Package{
|
|
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_PYPI,
|
|
Name: packageName,
|
|
},
|
|
Version: version,
|
|
},
|
|
Extras: extras,
|
|
IsExplicitVersion: isExplicit,
|
|
})
|
|
}
|
|
|
|
return &ParsedCommand{
|
|
Command: command,
|
|
InstallTargets: installTargets,
|
|
IsManifestInstall: isManifestInstall,
|
|
ManifestFiles: manifestFiles,
|
|
}, nil
|
|
}
|
|
|
|
type poetryCommandParser struct {
|
|
config PypiPackageManagerConfig
|
|
}
|
|
|
|
func NewPoetryCommandParser(config PypiPackageManagerConfig) pypiCommandParser {
|
|
return &poetryCommandParser{
|
|
config: config,
|
|
}
|
|
}
|
|
|
|
func (p *poetryCommandParser) ParseCommand(args []string) (*ParsedCommand, error) {
|
|
// Remove 'poetry' if it's the first argument
|
|
if len(args) > 0 && args[0] == "poetry" {
|
|
args = args[1:]
|
|
}
|
|
|
|
command := Command{Exe: p.config.CommandName, Args: args}
|
|
if len(args) < 1 {
|
|
return &ParsedCommand{Command: command}, nil
|
|
}
|
|
|
|
if len(args) > 0 && args[0] == "install" {
|
|
return &ParsedCommand{
|
|
Command: command,
|
|
IsManifestInstall: true,
|
|
InstallTargets: nil,
|
|
ManifestFiles: []string{"poetry.lock"},
|
|
}, nil
|
|
}
|
|
|
|
// Find the install command position
|
|
var installCmdIndex = -1
|
|
for idx, arg := range args {
|
|
if slices.Contains(p.config.InstallCommands, arg) {
|
|
installCmdIndex = idx
|
|
break
|
|
}
|
|
}
|
|
|
|
if installCmdIndex == -1 {
|
|
return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: IsFirstNonFlagArgInList(args, p.config.NonDownloadCommands)}, nil
|
|
}
|
|
|
|
// Extract arguments after the install command
|
|
installArgs := args[installCmdIndex+1:]
|
|
|
|
// Set up flag parsing
|
|
flagSet := pflag.NewFlagSet("poetry", pflag.ContinueOnError)
|
|
flagSet.ParseErrorsAllowlist.UnknownFlags = true
|
|
flagSet.SetOutput(io.Discard)
|
|
|
|
err := flagSet.Parse(installArgs)
|
|
if err != nil {
|
|
return &ParsedCommand{Command: command}, nil
|
|
}
|
|
|
|
packages := flagSet.Args()
|
|
|
|
var installTargets []*PackageInstallTarget
|
|
for _, pkg := range packages {
|
|
// Convert Poetry version constraints (^, ~, *) to standard format
|
|
convertedPkg, err := pypiConvertPoetryVersionConstraints(pkg)
|
|
if err != nil {
|
|
return nil, ErrFailedToParsePackage.Wrap(err)
|
|
}
|
|
|
|
packageName, version, extras, err := pypiParsePackageInfo(convertedPkg)
|
|
if err != nil {
|
|
return nil, ErrFailedToParsePackage.Wrap(err)
|
|
}
|
|
|
|
isExplicit := version != ""
|
|
|
|
version, err = pypiGetMatchingVersion(packageName, version)
|
|
if err != nil {
|
|
return nil, ErrFailedToResolveVersion.Wrap(err)
|
|
}
|
|
|
|
installTargets = append(installTargets, &PackageInstallTarget{
|
|
PackageVersion: &packagev1.PackageVersion{
|
|
Package: &packagev1.Package{
|
|
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_PYPI,
|
|
Name: packageName,
|
|
},
|
|
Version: version,
|
|
},
|
|
Extras: extras,
|
|
IsExplicitVersion: isExplicit,
|
|
})
|
|
}
|
|
|
|
return &ParsedCommand{
|
|
Command: command,
|
|
InstallTargets: installTargets,
|
|
IsManifestInstall: false,
|
|
ManifestFiles: nil,
|
|
}, nil
|
|
}
|
|
|
|
// pypiParsePackageInfo parses a python package installation specification, separating the package name,
|
|
// version constraints, and any extras (additional features) to be installed.
|
|
// Example: "django[mysql,redis]>=3.0" returns ("django", ">=3.0", ["mysql", "redis"], nil)
|
|
func pypiParsePackageInfo(input string) (packageName, version string, extras []string, err error) {
|
|
if input == "" {
|
|
return "", "", nil, fmt.Errorf("package info cannot be empty")
|
|
}
|
|
|
|
input = strings.TrimSpace(input)
|
|
|
|
// First extract any extras if present
|
|
openBracket := strings.Index(input, "[")
|
|
closeBracket := strings.Index(input, "]")
|
|
|
|
if openBracket != -1 && closeBracket != -1 && openBracket < closeBracket {
|
|
extrasStr := strings.TrimSpace(input[openBracket+1 : closeBracket])
|
|
if extrasStr != "" {
|
|
// Split extras by comma and trim each extra
|
|
for _, extra := range strings.Split(extrasStr, ",") {
|
|
if trimmedExtra := strings.TrimSpace(extra); trimmedExtra != "" {
|
|
extras = append(extras, trimmedExtra)
|
|
}
|
|
}
|
|
}
|
|
// Remove the extra part from input for further processing
|
|
input = input[:openBracket] + input[closeBracket+1:]
|
|
} else if (openBracket != -1 && closeBracket == -1) || (openBracket == -1 && closeBracket != -1) {
|
|
return "", "", nil, fmt.Errorf("mismatched brackets in input '%s'", input)
|
|
}
|
|
|
|
// Python package version specifiers are typically separated by one of:
|
|
// '==', '>=', '<=', '!=', '>', '<', '~=', or direct comma separated list
|
|
operators := []string{"==", ">=", "<=", "!=", ">", "<", "~="}
|
|
index := -1
|
|
|
|
// Find the earliest operator occurrence
|
|
for _, op := range operators {
|
|
i := strings.Index(input, op)
|
|
if i != -1 && (index == -1 || i < index) {
|
|
index = i
|
|
}
|
|
}
|
|
|
|
if index == -1 {
|
|
// No operator found, whole input is package name, no version
|
|
return strings.TrimSpace(input), "", extras, nil
|
|
}
|
|
|
|
packageName = strings.TrimSpace(input[:index])
|
|
version = strings.TrimSpace(input[index:])
|
|
|
|
if packageName == "" {
|
|
return "", "", nil, fmt.Errorf("invalid package name in input '%s'", input)
|
|
}
|
|
|
|
return packageName, version, extras, nil
|
|
}
|
|
|
|
// pypiConvertPoetryVersionConstraints converts Poetry's caret (^) and tilde (~) version constraints
|
|
// to equivalent version ranges. It preserves extras and package names exactly.
|
|
// Examples:
|
|
// - "django[mysql]^3.0" -> "django[mysql]>=3.0,<4.0.0"
|
|
// - "requests@*" -> "requests>=0.0.0"
|
|
// - "flask@1.2.*" -> "flask>=1.2.0,<1.3.0"
|
|
// - "pendulum>=2.0.0" -> "pendulum>=2.0.0" (unchanged)
|
|
func pypiConvertPoetryVersionConstraints(packageStr string) (string, error) {
|
|
if packageStr == "" {
|
|
return "", fmt.Errorf("package string cannot be empty")
|
|
}
|
|
|
|
packageStr = strings.TrimSpace(packageStr)
|
|
|
|
// Return early is the package name is not valid
|
|
if strings.HasPrefix(packageStr, "@") || strings.HasPrefix(packageStr, "^") ||
|
|
strings.HasPrefix(packageStr, "~") || regexp.MustCompile(`^[\d.*]`).MatchString(packageStr) {
|
|
return "", fmt.Errorf("invalid package specification: '%s' appears to be a version constraint without a package name", packageStr)
|
|
}
|
|
|
|
// Regex to match package with optional extras and Poetry constraints
|
|
// Matches: packagename[extras]@^version, packagename[extras]^version, or packagename[extras]@*
|
|
poetryConstraintRegex := regexp.MustCompile(`^([a-zA-Z0-9._-]+(?:\[[^\]]*\])?)(?:@)?([~^]|\*|[\d.]+\*)(.*)$`)
|
|
|
|
matches := poetryConstraintRegex.FindStringSubmatch(packageStr)
|
|
if len(matches) != 4 {
|
|
return packageStr, nil
|
|
}
|
|
|
|
packageName := matches[1]
|
|
operator := matches[2]
|
|
version := matches[3]
|
|
|
|
// Convert based on operator type
|
|
var convertedRange string
|
|
if operator == "^" {
|
|
convertedRange = pypiConvertCaretConstraint(version)
|
|
} else if operator == "~" {
|
|
convertedRange = pypiConvertTildeConstraint(version)
|
|
} else if operator == "*" || strings.HasSuffix(operator, "*") {
|
|
// For wildcards, the operator contains the full wildcard pattern
|
|
convertedRange = pypiConvertWildcardConstraint(operator)
|
|
}
|
|
|
|
if convertedRange == "" {
|
|
return "", fmt.Errorf("invalid version constraint: %s%s", operator, version)
|
|
}
|
|
|
|
return packageName + convertedRange, nil
|
|
}
|
|
|
|
// pypiConvertCaretConstraint converts caret (^) version constraints to equivalent ranges
|
|
// Examples:
|
|
// - "1.2.3" -> ">=1.2.3,<2.0.0"
|
|
// - "0.2.3" -> ">=0.2.3,<0.3.0" (special case for major version 0)
|
|
// - "0.0.3" -> ">=0.0.3,<0.0.4" (special case for major and minor version 0)
|
|
func pypiConvertCaretConstraint(version string) string {
|
|
parts := strings.Split(version, ".")
|
|
if len(parts) < 1 {
|
|
return "" // invalid
|
|
}
|
|
|
|
// Pad with zeros if needed (e.g., "1.2" -> "1.2.0")
|
|
for len(parts) < 3 {
|
|
parts = append(parts, "0")
|
|
}
|
|
|
|
major, err := strconv.Atoi(parts[0])
|
|
if err != nil {
|
|
return "" // invalid major version
|
|
}
|
|
|
|
minor, err := strconv.Atoi(parts[1])
|
|
if err != nil {
|
|
return "" // invalid minor version
|
|
}
|
|
|
|
patch, err := strconv.Atoi(parts[2])
|
|
if err != nil {
|
|
return "" // invalid patch version
|
|
}
|
|
|
|
// Special cases for version 0.x.x
|
|
if major == 0 {
|
|
if minor == 0 {
|
|
// ^0.0.x -> >=0.0.x,<0.0.(x+1)
|
|
return fmt.Sprintf(">=0.0.%d,<0.0.%d", patch, patch+1)
|
|
}
|
|
// ^0.x.y -> >=0.x.y,<0.(x+1).0
|
|
return fmt.Sprintf(">=0.%d.%d,<0.%d.0", minor, patch, minor+1)
|
|
}
|
|
|
|
// ^x.y.z -> >=x.y.z,<(x+1).0.0
|
|
// Reconstruct the original version with proper formatting
|
|
originalVersion := strings.Join(parts, ".")
|
|
return fmt.Sprintf(">=%s,<%d.0.0", originalVersion, major+1)
|
|
}
|
|
|
|
// pypiConvertTildeConstraint converts tilde (~) version constraints to equivalent ranges
|
|
// Examples:
|
|
// - "1.2.3" -> ">=1.2.3,<1.3.0"
|
|
// - "1.2" -> ">=1.2.0,<1.3.0"
|
|
// - "1" -> ">=1.0.0,<2.0.0"
|
|
func pypiConvertTildeConstraint(version string) string {
|
|
parts := strings.Split(version, ".")
|
|
if len(parts) < 1 {
|
|
return "" // invalid
|
|
}
|
|
|
|
major, err := strconv.Atoi(parts[0])
|
|
if err != nil {
|
|
return "" // invalid major version
|
|
}
|
|
|
|
switch len(parts) {
|
|
case 1:
|
|
// ~1 -> >=1.0.0,<2.0.0
|
|
return fmt.Sprintf(">=%s.0.0,<%d.0.0", version, major+1)
|
|
case 2:
|
|
// ~1.2 -> >=1.2.0,<1.3.0
|
|
minor, err := strconv.Atoi(parts[1])
|
|
if err != nil {
|
|
return "" // invalid minor version
|
|
}
|
|
return fmt.Sprintf(">=%s.0,<%d.%d.0", version, major, minor+1)
|
|
default:
|
|
// ~1.2.3 -> >=1.2.3,<1.3.0
|
|
minor, err := strconv.Atoi(parts[1])
|
|
if err != nil {
|
|
return "" // invalid minor version
|
|
}
|
|
return fmt.Sprintf(">=%s,<%d.%d.0", version, major, minor+1)
|
|
}
|
|
}
|
|
|
|
func pypiConvertCompatibleRelease(version string) string {
|
|
if !strings.HasPrefix(version, "~=") {
|
|
return version
|
|
}
|
|
|
|
version = strings.TrimPrefix(version, "~=")
|
|
parts := strings.Split(version, ".")
|
|
if len(parts) < 2 {
|
|
return "" // invalid
|
|
}
|
|
|
|
switch len(parts) {
|
|
case 2:
|
|
// ~=X.Y case, increment major version: ~=2.1 -> >=2.1,<3.0
|
|
major := parts[0]
|
|
nextMajor, _ := strconv.Atoi(major)
|
|
nextMajor += 1
|
|
return fmt.Sprintf(">=%s,<%d.0", version, nextMajor)
|
|
|
|
case 3:
|
|
// ~=X.Y.Z case, increment minor version: ~=2.1.5 -> >=2.1.5,<2.2.0
|
|
major := parts[0]
|
|
minor := parts[1]
|
|
nextMinor, _ := strconv.Atoi(minor)
|
|
nextMinor += 1
|
|
return fmt.Sprintf(">=%s,<%s.%d.0", version, major, nextMinor)
|
|
|
|
default:
|
|
// ~=X.Y.Z.W[.more] case, increment second-to-last component
|
|
// ~=2.1.5.2 -> >=2.1.5.2,<2.1.6
|
|
incIndex := len(parts) - 2
|
|
upperBoundParts := make([]string, incIndex+1)
|
|
copy(upperBoundParts, parts[:incIndex+1])
|
|
|
|
increment, _ := strconv.Atoi(upperBoundParts[incIndex])
|
|
increment++
|
|
upperBoundParts[incIndex] = strconv.Itoa(increment)
|
|
|
|
upperBound := strings.Join(upperBoundParts, ".")
|
|
return fmt.Sprintf(">=%s,<%s", version, upperBound)
|
|
}
|
|
}
|
|
|
|
// pypiConvertWildcardConstraint converts wildcard (*) version constraints to equivalent ranges
|
|
// Examples:
|
|
// - "*" -> ">=0.0.0"
|
|
// - "1.*" -> ">=1.0.0,<2.0.0"
|
|
// - "1.2.*" -> ">=1.2.0,<1.3.0"
|
|
func pypiConvertWildcardConstraint(wildcard string) string {
|
|
if wildcard == "*" {
|
|
return ">=0.0.0"
|
|
}
|
|
|
|
// Remove trailing .* to get the base version
|
|
if !strings.HasSuffix(wildcard, ".*") {
|
|
return "" // invalid wildcard format
|
|
}
|
|
|
|
baseVersion := strings.TrimSuffix(wildcard, ".*")
|
|
parts := strings.Split(baseVersion, ".")
|
|
|
|
// Validate that all parts are numeric
|
|
for _, part := range parts {
|
|
if _, err := strconv.Atoi(part); err != nil {
|
|
return "" // invalid version part
|
|
}
|
|
}
|
|
|
|
// Normalize to 3 parts and create range
|
|
switch len(parts) {
|
|
case 1:
|
|
// 1.* -> >=1.0.0,<2.0.0
|
|
major, _ := strconv.Atoi(parts[0])
|
|
return fmt.Sprintf(">=%d.0.0,<%d.0.0", major, major+1)
|
|
case 2:
|
|
// 1.2.* -> >=1.2.0,<1.3.0
|
|
major, _ := strconv.Atoi(parts[0])
|
|
minor, _ := strconv.Atoi(parts[1])
|
|
return fmt.Sprintf(">=%d.%d.0,<%d.%d.0", major, minor, major, minor+1)
|
|
default:
|
|
return "" // unsupported wildcard format
|
|
}
|
|
}
|