mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
add poetry command (#67)
* add poetry command * add poetry.lock extractor & define cmd parser for poetry * feat: support Poetry caret/tilde version syntax * add wildcard constraint support & test cases * readme update * chore: small fixes
This commit is contained in:
@@ -91,8 +91,8 @@ PMG supports the following package managers:
|
|||||||
| `bun` | ✅ Active | `pmg bun add <package>` |
|
| `bun` | ✅ Active | `pmg bun add <package>` |
|
||||||
| `pip` | ✅ Active | `pmg pip install <package>` |
|
| `pip` | ✅ Active | `pmg pip install <package>` |
|
||||||
| `uv` | ✅ Active | `pmg uv add <package>` or `pmg uv pip install <package>`|
|
| `uv` | ✅ Active | `pmg uv add <package>` or `pmg uv pip install <package>`|
|
||||||
|
| `poetry` | ✅ Active | `pmg poetry add <package>` |
|
||||||
| `yarn` | 🚧 Planned | |
|
| `yarn` | 🚧 Planned | |
|
||||||
| `poetry` | 🚧 Planned | |
|
|
||||||
|
|
||||||
> Want us to support your favorite package manager? [Open an issue](https://github.com/safedep/pmg/issues) and let us know!
|
> Want us to support your favorite package manager? [Open an issue](https://github.com/safedep/pmg/issues) and let us know!
|
||||||
|
|
||||||
@@ -174,6 +174,8 @@ pip install <package-name>
|
|||||||
|
|
||||||
uv add <package-name>
|
uv add <package-name>
|
||||||
uv pip install <package-name>
|
uv pip install <package-name>
|
||||||
|
|
||||||
|
poetry add <package-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Alternative: Manual Commands
|
### Alternative: Manual Commands
|
||||||
@@ -188,6 +190,8 @@ pmg pip install <package-name>
|
|||||||
|
|
||||||
pmg uv add <package-name>
|
pmg uv add <package-name>
|
||||||
pmg uv pip install <package-name>
|
pmg uv pip install <package-name>
|
||||||
|
|
||||||
|
pmg poetry add <package-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Lockfile Installation
|
### Lockfile Installation
|
||||||
@@ -203,6 +207,8 @@ pip install -r requirements.txt # Uses requirements file
|
|||||||
uv sync # Installs packages from uv.lock
|
uv sync # Installs packages from uv.lock
|
||||||
uv pip sync requirements.txt # Sync from requirements file
|
uv pip sync requirements.txt # Sync from requirements file
|
||||||
uv pip install -r requirements.txt
|
uv pip install -r requirements.txt
|
||||||
|
|
||||||
|
poetry install # Installs from poetry.lock
|
||||||
```
|
```
|
||||||
|
|
||||||
PMG scans the exact package versions specified in lockfiles and blocks installation if malicious packages are detected.
|
PMG scans the exact package versions specified in lockfiles and blocks installation if malicious packages are detected.
|
||||||
@@ -290,7 +296,7 @@ is malicious. However, there is a possibility of inconsistency when a specific v
|
|||||||
<details>
|
<details>
|
||||||
<summary>PyPI registry scanning only</summary>
|
<summary>PyPI registry scanning only</summary>
|
||||||
|
|
||||||
`pmg` only scans packages available in the PyPI registry when using `pip`. Packages installed from
|
`pmg` only scans packages available in the PyPI registry when using any python package manager. Packages installed from
|
||||||
alternative sources such as Git URLs, local file paths, or private registries are not analyzed for
|
alternative sources such as Git URLs, local file paths, or private registries are not analyzed for
|
||||||
malware detection. This limitation applies to direct installations and transitive dependencies sourced
|
malware detection. This limitation applies to direct installations and transitive dependencies sourced
|
||||||
from non-PyPI locations.
|
from non-PyPI locations.
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package pypi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/safedep/pmg/config"
|
||||||
|
"github.com/safedep/pmg/internal/analytics"
|
||||||
|
"github.com/safedep/pmg/internal/flows"
|
||||||
|
"github.com/safedep/pmg/internal/ui"
|
||||||
|
"github.com/safedep/pmg/packagemanager"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewPoetryCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "poetry [action] [package]",
|
||||||
|
Short: "Guard poetry package manager",
|
||||||
|
DisableFlagParsing: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
err := executePoetryFlow(cmd.Context(), args)
|
||||||
|
if err != nil {
|
||||||
|
ui.ErrorExit(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func executePoetryFlow(ctx context.Context, args []string) error {
|
||||||
|
analytics.TrackCommandPoetry()
|
||||||
|
packageManager, err := packagemanager.NewPypiPackageManager(packagemanager.DefaultPoetryPackageManagerConfig())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create poetry package manager: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := config.FromContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedCommand, err := packageManager.ParseCommand(args)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
packageResolverConfig := packagemanager.NewDefaultPypiDependencyResolverConfig()
|
||||||
|
packageResolverConfig.IncludeTransitiveDependencies = config.Transitive
|
||||||
|
packageResolverConfig.TransitiveDepth = config.TransitiveDepth
|
||||||
|
packageResolverConfig.IncludeDevDependencies = config.IncludeDevDependencies
|
||||||
|
packageResolverConfig.PackageInstallTargets = parsedCommand.InstallTargets
|
||||||
|
|
||||||
|
packageResolver, err := packagemanager.NewPypiDependencyResolver(packageResolverConfig)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create dependency resolver: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return flows.Common(packageManager, packageResolver, config).Run(ctx, args, parsedCommand)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/google/osv-scalibr/extractor/filesystem/language/javascript/bunlock"
|
"github.com/google/osv-scalibr/extractor/filesystem/language/javascript/bunlock"
|
||||||
"github.com/google/osv-scalibr/extractor/filesystem/language/javascript/packagelockjson"
|
"github.com/google/osv-scalibr/extractor/filesystem/language/javascript/packagelockjson"
|
||||||
"github.com/google/osv-scalibr/extractor/filesystem/language/javascript/pnpmlock"
|
"github.com/google/osv-scalibr/extractor/filesystem/language/javascript/pnpmlock"
|
||||||
|
"github.com/google/osv-scalibr/extractor/filesystem/language/python/poetrylock"
|
||||||
"github.com/google/osv-scalibr/extractor/filesystem/language/python/requirements"
|
"github.com/google/osv-scalibr/extractor/filesystem/language/python/requirements"
|
||||||
"github.com/google/osv-scalibr/extractor/filesystem/language/python/uvlock"
|
"github.com/google/osv-scalibr/extractor/filesystem/language/python/uvlock"
|
||||||
"github.com/google/osv-scalibr/fs"
|
"github.com/google/osv-scalibr/fs"
|
||||||
@@ -34,6 +35,8 @@ func getExtractorForFile(filename string) (filesystem.Extractor, error) {
|
|||||||
return requirements.NewDefault(), nil
|
return requirements.NewDefault(), nil
|
||||||
case filename == "uv.lock":
|
case filename == "uv.lock":
|
||||||
return uvlock.New(), nil
|
return uvlock.New(), nil
|
||||||
|
case filename == "poetry.lock":
|
||||||
|
return poetrylock.New(), nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported lockfile type: %s", filename)
|
return nil, fmt.Errorf("unsupported lockfile type: %s", filename)
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-10
@@ -22,11 +22,12 @@ type PackageManagerExtractor interface {
|
|||||||
type PackageManagerName string
|
type PackageManagerName string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Npm PackageManagerName = "npm"
|
Npm PackageManagerName = "npm"
|
||||||
Pnpm PackageManagerName = "pnpm"
|
Pnpm PackageManagerName = "pnpm"
|
||||||
Pip PackageManagerName = "pip"
|
Pip PackageManagerName = "pip"
|
||||||
Bun PackageManagerName = "bun"
|
Bun PackageManagerName = "bun"
|
||||||
Uv PackageManagerName = "uv"
|
Uv PackageManagerName = "uv"
|
||||||
|
Poetry PackageManagerName = "poetry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ExtractorManager struct {
|
type ExtractorManager struct {
|
||||||
@@ -36,11 +37,12 @@ type ExtractorManager struct {
|
|||||||
func NewExtractorManager() *ExtractorManager {
|
func NewExtractorManager() *ExtractorManager {
|
||||||
return &ExtractorManager{
|
return &ExtractorManager{
|
||||||
extractors: map[PackageManagerName]PackageManagerExtractor{
|
extractors: map[PackageManagerName]PackageManagerExtractor{
|
||||||
Npm: &NpmExtractor{},
|
Npm: &NpmExtractor{},
|
||||||
Pnpm: &PnpmExtractor{},
|
Pnpm: &PnpmExtractor{},
|
||||||
Pip: &PipExtractor{},
|
Pip: &PipExtractor{},
|
||||||
Bun: &BunExtractor{},
|
Bun: &BunExtractor{},
|
||||||
Uv: &UvExtractor{},
|
Uv: &UvExtractor{},
|
||||||
|
Poetry: &PoetryExtractor{},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,3 +41,22 @@ func (u *UvExtractor) GetPackageManager() PackageManagerName {
|
|||||||
func (u *UvExtractor) Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
|
func (u *UvExtractor) Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
|
||||||
return parseLockfile(lockfilePath, scanDir, u.GetEcosystem())
|
return parseLockfile(lockfilePath, scanDir, u.GetEcosystem())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PoetryExtractor handles poetry.lock files
|
||||||
|
type PoetryExtractor struct{}
|
||||||
|
|
||||||
|
func (p *PoetryExtractor) GetSupportedFiles() []string {
|
||||||
|
return []string{"poetry.lock"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PoetryExtractor) GetEcosystem() packagev1.Ecosystem {
|
||||||
|
return packagev1.Ecosystem_ECOSYSTEM_PYPI
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PoetryExtractor) GetPackageManager() PackageManagerName {
|
||||||
|
return Poetry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PoetryExtractor) Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
|
||||||
|
return parseLockfile(lockfilePath, scanDir, p.GetEcosystem())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
package analytics
|
package analytics
|
||||||
|
|
||||||
const (
|
const (
|
||||||
eventRun = "pmg_command_run"
|
eventRun = "pmg_command_run"
|
||||||
eventCommandNpm = "pmg_command_npm"
|
eventCommandNpm = "pmg_command_npm"
|
||||||
eventCommandBun = "pmg_command_bun"
|
eventCommandBun = "pmg_command_bun"
|
||||||
eventCommandPnpm = "pmg_command_pnpm"
|
eventCommandPnpm = "pmg_command_pnpm"
|
||||||
eventCommandPip = "pmg_command_pip"
|
eventCommandPip = "pmg_command_pip"
|
||||||
eventCommandUv = "pmg_command_uv"
|
eventCommandUv = "pmg_command_uv"
|
||||||
|
eventCommandPoetry = "pmg_command_poetry"
|
||||||
|
|
||||||
eventPmgGenerateEnvDocker = "pmg_command_generate_env_docker"
|
eventPmgGenerateEnvDocker = "pmg_command_generate_env_docker"
|
||||||
eventPmgGenerateEnvGitHubActions = "pmg_command_generate_env_github_actions"
|
eventPmgGenerateEnvGitHubActions = "pmg_command_generate_env_github_actions"
|
||||||
@@ -37,6 +38,10 @@ func TrackCommandUv() {
|
|||||||
TrackEvent(eventCommandUv)
|
TrackEvent(eventCommandUv)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TrackCommandPoetry() {
|
||||||
|
TrackEvent(eventCommandPoetry)
|
||||||
|
}
|
||||||
|
|
||||||
func TrackCommandGenerateEnvDocker() {
|
func TrackCommandGenerateEnvDocker() {
|
||||||
TrackEvent(eventPmgGenerateEnvDocker)
|
TrackEvent(eventPmgGenerateEnvDocker)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ func main() {
|
|||||||
cmd.AddCommand(npm.NewBunCommand())
|
cmd.AddCommand(npm.NewBunCommand())
|
||||||
cmd.AddCommand(pypi.NewPipCommand())
|
cmd.AddCommand(pypi.NewPipCommand())
|
||||||
cmd.AddCommand(pypi.NewUvCommand())
|
cmd.AddCommand(pypi.NewUvCommand())
|
||||||
|
cmd.AddCommand(pypi.NewPoetryCommand())
|
||||||
cmd.AddCommand(version.NewVersionCommand())
|
cmd.AddCommand(version.NewVersionCommand())
|
||||||
cmd.AddCommand(setup.NewSetupCommand())
|
cmd.AddCommand(setup.NewSetupCommand())
|
||||||
cmd.AddCommand(setup.NewRemoveCommand())
|
cmd.AddCommand(setup.NewRemoveCommand())
|
||||||
|
|||||||
+289
-19
@@ -3,6 +3,7 @@ package packagemanager
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -35,6 +36,13 @@ func DefaultUvPackageManagerConfig() PypiPackageManagerConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func DefaultPoetryPackageManagerConfig() PypiPackageManagerConfig {
|
||||||
|
return PypiPackageManagerConfig{
|
||||||
|
InstallCommands: []string{"add"},
|
||||||
|
CommandName: "poetry",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type pypiPackageManager struct {
|
type pypiPackageManager struct {
|
||||||
Config PypiPackageManagerConfig
|
Config PypiPackageManagerConfig
|
||||||
parser pypiCommandParser
|
parser pypiCommandParser
|
||||||
@@ -48,6 +56,8 @@ func NewPypiPackageManager(config PypiPackageManagerConfig) (*pypiPackageManager
|
|||||||
parser = NewPipCommandParser(config)
|
parser = NewPipCommandParser(config)
|
||||||
case "uv":
|
case "uv":
|
||||||
parser = NewUVCommandParser(config)
|
parser = NewUVCommandParser(config)
|
||||||
|
case "poetry":
|
||||||
|
parser = NewPoetryCommandParser(config)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported package manager: %s", config.CommandName)
|
return nil, fmt.Errorf("unsupported package manager: %s", config.CommandName)
|
||||||
}
|
}
|
||||||
@@ -145,15 +155,9 @@ func (p *pipCommandParser) ParseCommand(args []string) (*ParsedCommand, error) {
|
|||||||
return nil, ErrFailedToParsePackage.Wrap(err)
|
return nil, ErrFailedToParsePackage.Wrap(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if version != "" {
|
version, err = pypiGetMatchingVersion(packageName, version)
|
||||||
if strings.HasPrefix(version, "==") {
|
if err != nil {
|
||||||
version = strings.TrimPrefix(version, "==")
|
return nil, ErrFailedToResolveVersion.Wrap(err)
|
||||||
} else {
|
|
||||||
version, err = pypiGetMatchingVersion(packageName, version)
|
|
||||||
if err != nil {
|
|
||||||
return nil, ErrFailedToResolveVersion.Wrap(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
installTargets = append(installTargets, &PackageInstallTarget{
|
installTargets = append(installTargets, &PackageInstallTarget{
|
||||||
@@ -262,15 +266,9 @@ func (u *uvCommandParser) ParseCommand(args []string) (*ParsedCommand, error) {
|
|||||||
return nil, ErrFailedToParsePackage.Wrap(err)
|
return nil, ErrFailedToParsePackage.Wrap(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if version != "" {
|
version, err = pypiGetMatchingVersion(packageName, version)
|
||||||
if strings.HasPrefix(version, "==") {
|
if err != nil {
|
||||||
version = strings.TrimPrefix(version, "==")
|
return nil, ErrFailedToResolveVersion.Wrap(err)
|
||||||
} else {
|
|
||||||
version, err = pypiGetMatchingVersion(packageName, version)
|
|
||||||
if err != nil {
|
|
||||||
return nil, ErrFailedToResolveVersion.Wrap(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
installTargets = append(installTargets, &PackageInstallTarget{
|
installTargets = append(installTargets, &PackageInstallTarget{
|
||||||
@@ -293,6 +291,103 @@ func (u *uvCommandParser) ParseCommand(args []string) (*ParsedCommand, error) {
|
|||||||
}, nil
|
}, 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 {
|
||||||
|
// No install command found, return as-is
|
||||||
|
return &ParsedCommand{Command: command}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract arguments after the install command
|
||||||
|
installArgs := args[installCmdIndex+1:]
|
||||||
|
|
||||||
|
// Set up flag parsing
|
||||||
|
flagSet := pflag.NewFlagSet("poetry", pflag.ContinueOnError)
|
||||||
|
flagSet.ParseErrorsWhitelist.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ParsedCommand{
|
||||||
|
Command: command,
|
||||||
|
InstallTargets: installTargets,
|
||||||
|
IsManifestInstall: false,
|
||||||
|
ManifestFiles: nil,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// pypiParsePackageInfo parses a python package installation specification, separating the package name,
|
// pypiParsePackageInfo parses a python package installation specification, separating the package name,
|
||||||
// version constraints, and any extras (additional features) to be installed.
|
// version constraints, and any extras (additional features) to be installed.
|
||||||
// Example: "django[mysql,redis]>=3.0" returns ("django", ">=3.0", ["mysql", "redis"], nil)
|
// Example: "django[mysql,redis]>=3.0" returns ("django", ">=3.0", ["mysql", "redis"], nil)
|
||||||
@@ -351,6 +446,141 @@ func pypiParsePackageInfo(input string) (packageName, version string, extras []s
|
|||||||
return packageName, version, extras, nil
|
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 {
|
func pypiConvertCompatibleRelease(version string) string {
|
||||||
if !strings.HasPrefix(version, "~=") {
|
if !strings.HasPrefix(version, "~=") {
|
||||||
return version
|
return version
|
||||||
@@ -390,7 +620,47 @@ func pypiConvertCompatibleRelease(version string) string {
|
|||||||
upperBoundParts[incIndex] = strconv.Itoa(increment)
|
upperBoundParts[incIndex] = strconv.Itoa(increment)
|
||||||
|
|
||||||
upperBound := strings.Join(upperBoundParts, ".")
|
upperBound := strings.Join(upperBoundParts, ".")
|
||||||
|
|
||||||
return fmt.Sprintf(">=%s,<%s", version, upperBound)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -249,14 +249,15 @@ func pypiParseDependency(input string) (string, string, string) {
|
|||||||
|
|
||||||
func pypiGetMatchingVersion(packageName, versionConstraint string) (string, error) {
|
func pypiGetMatchingVersion(packageName, versionConstraint string) (string, error) {
|
||||||
// Already a exact version
|
// Already a exact version
|
||||||
if strings.HasPrefix(versionConstraint, "==") {
|
if after, ok := strings.CutPrefix(versionConstraint, "=="); ok {
|
||||||
return versionConstraint, nil
|
return after, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle compatible release operator
|
// Handle compatible release operator
|
||||||
if strings.HasPrefix(versionConstraint, "~=") {
|
if strings.HasPrefix(versionConstraint, "~=") {
|
||||||
versionConstraint = pypiConvertCompatibleRelease(versionConstraint)
|
versionConstraint = pypiConvertCompatibleRelease(versionConstraint)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle empty version constraint
|
// Handle empty version constraint
|
||||||
if versionConstraint == "" {
|
if versionConstraint == "" {
|
||||||
// Get latest version
|
// Get latest version
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ func TestPipGetLatestMatchingVersion(t *testing.T) {
|
|||||||
versionConstraint: "==2.28.0",
|
versionConstraint: "==2.28.0",
|
||||||
assertFn: func(t *testing.T, version string, err error) {
|
assertFn: func(t *testing.T, version string, err error) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, "==2.28.0", version)
|
require.Equal(t, "2.28.0", version)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -251,6 +251,348 @@ func TestPipConvertCompatibleRelease(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPypiConvertPoetryVersionConstraints(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
// Basic functionality tests
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
input: "",
|
||||||
|
expected: "",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "package name only",
|
||||||
|
input: "pendulum",
|
||||||
|
expected: "pendulum",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inequality constraints",
|
||||||
|
input: "pendulum>=2.0.0",
|
||||||
|
expected: "pendulum>=2.0.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty package name",
|
||||||
|
input: "@^1.0.0",
|
||||||
|
expected: "",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace handling",
|
||||||
|
input: " pendulum@^2.0.5 ",
|
||||||
|
expected: "pendulum>=2.0.5,<3.0.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Caret constraint tests
|
||||||
|
{
|
||||||
|
name: "caret basic major version",
|
||||||
|
input: "pendulum@^2.0.5",
|
||||||
|
expected: "pendulum>=2.0.5,<3.0.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "caret with major version 0",
|
||||||
|
input: "requests@^0.2.3",
|
||||||
|
expected: "requests>=0.2.3,<0.3.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "caret with major and minor version 0",
|
||||||
|
input: "numpy@^0.0.5",
|
||||||
|
expected: "numpy>=0.0.5,<0.0.6",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "caret with two parts",
|
||||||
|
input: "flask@^1.1",
|
||||||
|
expected: "flask>=1.1.0,<2.0.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "caret with one part",
|
||||||
|
input: "pytest@^7",
|
||||||
|
expected: "pytest>=7.0.0,<8.0.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "caret with zero major & two parts",
|
||||||
|
input: "package@^0.5",
|
||||||
|
expected: "package>=0.5.0,<0.6.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "caret invalid version",
|
||||||
|
input: "invalid@^abc.def",
|
||||||
|
expected: "",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tilde constraint tests
|
||||||
|
{
|
||||||
|
name: "tilde basic three parts",
|
||||||
|
input: "pendulum@~2.0.5",
|
||||||
|
expected: "pendulum>=2.0.5,<2.1.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tilde with two parts",
|
||||||
|
input: "requests@~1.2",
|
||||||
|
expected: "requests>=1.2.0,<1.3.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tilde with one part",
|
||||||
|
input: "numpy@~2",
|
||||||
|
expected: "numpy>=2.0.0,<3.0.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tilde with zero major",
|
||||||
|
input: "package@~0.5.2",
|
||||||
|
expected: "package>=0.5.2,<0.6.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tilde invalid version",
|
||||||
|
input: "invalid@~abc.def",
|
||||||
|
expected: "",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tilde without @ separator",
|
||||||
|
input: "requests~1.2.0",
|
||||||
|
expected: "requests>=1.2.0,<1.3.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// With extras
|
||||||
|
{
|
||||||
|
name: "caret with extras using @ format",
|
||||||
|
input: "fastapi[all]@^0.68.0",
|
||||||
|
expected: "fastapi[all]>=0.68.0,<0.69.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "caret with extras without @ separator",
|
||||||
|
input: "django[mysql,redis]^3.0",
|
||||||
|
expected: "django[mysql,redis]>=3.0.0,<4.0.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tilde with multiple extras",
|
||||||
|
input: "uvicorn[standard,dev]~0.15.0",
|
||||||
|
expected: "uvicorn[standard,dev]>=0.15.0,<0.16.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty extras with caret",
|
||||||
|
input: "numpy[]^1.20.0",
|
||||||
|
expected: "numpy[]>=1.20.0,<2.0.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Standard constraint pass-through tests (no Poetry operators)
|
||||||
|
{
|
||||||
|
name: "standard python format",
|
||||||
|
input: "pendulum>=2.0.0",
|
||||||
|
expected: "pendulum>=2.0.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "standard exact version",
|
||||||
|
input: "django==3.2.0",
|
||||||
|
expected: "django==3.2.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "standard with extras",
|
||||||
|
input: "fastapi[all]>=0.68.0",
|
||||||
|
expected: "fastapi[all]>=0.68.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complex version range",
|
||||||
|
input: "requests>=2.0,<3.0",
|
||||||
|
expected: "requests>=2.0,<3.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Wildcard constraint tests
|
||||||
|
{
|
||||||
|
name: "wildcard all versions",
|
||||||
|
input: "requests@*",
|
||||||
|
expected: "requests>=0.0.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard major version",
|
||||||
|
input: "django@1.*",
|
||||||
|
expected: "django>=1.0.0,<2.0.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard minor version",
|
||||||
|
input: "flask@1.2.*",
|
||||||
|
expected: "flask>=1.2.0,<1.3.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard without @ separator",
|
||||||
|
input: "numpy@2.*",
|
||||||
|
expected: "numpy>=2.0.0,<3.0.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard with extras",
|
||||||
|
input: "fastapi[all]@1.*",
|
||||||
|
expected: "fastapi[all]>=1.0.0,<2.0.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard with extras without @ separator",
|
||||||
|
input: "uvicorn[standard]@0.*",
|
||||||
|
expected: "uvicorn[standard]>=0.0.0,<1.0.0",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
result, err := pypiConvertPoetryVersionConstraints(tc.input)
|
||||||
|
if tc.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPypiConvertCaretConstraint(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "standard major version",
|
||||||
|
input: "1.2.3",
|
||||||
|
expected: ">=1.2.3,<2.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "major version zero",
|
||||||
|
input: "0.2.3",
|
||||||
|
expected: ">=0.2.3,<0.3.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "major and minor version zero",
|
||||||
|
input: "0.0.3",
|
||||||
|
expected: ">=0.0.3,<0.0.4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "two parts",
|
||||||
|
input: "1.2",
|
||||||
|
expected: ">=1.2.0,<2.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one part",
|
||||||
|
input: "7",
|
||||||
|
expected: ">=7.0.0,<8.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero major two parts",
|
||||||
|
input: "0.5",
|
||||||
|
expected: ">=0.5.0,<0.6.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double digit versions",
|
||||||
|
input: "10.15.22",
|
||||||
|
expected: ">=10.15.22,<11.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid version",
|
||||||
|
input: "abc.def",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty version",
|
||||||
|
input: "",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
result := pypiConvertCaretConstraint(tc.input)
|
||||||
|
assert.Equal(t, tc.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPypiConvertTildeConstraint(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "three parts",
|
||||||
|
input: "1.2.3",
|
||||||
|
expected: ">=1.2.3,<1.3.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "two parts",
|
||||||
|
input: "1.2",
|
||||||
|
expected: ">=1.2.0,<1.3.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one part",
|
||||||
|
input: "2",
|
||||||
|
expected: ">=2.0.0,<3.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero major version",
|
||||||
|
input: "0.5.2",
|
||||||
|
expected: ">=0.5.2,<0.6.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double digit versions",
|
||||||
|
input: "10.15.22",
|
||||||
|
expected: ">=10.15.22,<10.16.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "large version numbers",
|
||||||
|
input: "99.99.99",
|
||||||
|
expected: ">=99.99.99,<99.100.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid version",
|
||||||
|
input: "abc.def",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty version",
|
||||||
|
input: "",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
result := pypiConvertTildeConstraint(tc.input)
|
||||||
|
assert.Equal(t, tc.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUvParseCommand(t *testing.T) {
|
func TestUvParseCommand(t *testing.T) {
|
||||||
pm, err := NewPypiPackageManager(DefaultUvPackageManagerConfig())
|
pm, err := NewPypiPackageManager(DefaultUvPackageManagerConfig())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -360,3 +702,64 @@ func TestUvParseCommand(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPypiConvertWildcardConstraint(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "all versions wildcard",
|
||||||
|
input: "*",
|
||||||
|
expected: ">=0.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "major version wildcard",
|
||||||
|
input: "1.*",
|
||||||
|
expected: ">=1.0.0,<2.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "minor version wildcard",
|
||||||
|
input: "2.5.*",
|
||||||
|
expected: ">=2.5.0,<2.6.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero major version wildcard",
|
||||||
|
input: "0.*",
|
||||||
|
expected: ">=0.0.0,<1.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero minor version wildcard",
|
||||||
|
input: "1.0.*",
|
||||||
|
expected: ">=1.0.0,<1.1.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "high version numbers",
|
||||||
|
input: "12.34.*",
|
||||||
|
expected: ">=12.34.0,<12.35.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid wildcard format",
|
||||||
|
input: "1.2.3.*",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid non-numeric parts",
|
||||||
|
input: "abc.*",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard without dot",
|
||||||
|
input: "1*",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
result := pypiConvertWildcardConstraint(tc.input)
|
||||||
|
assert.Equal(t, tc.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user