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:
Sahil Bansal
2025-08-29 00:21:09 +05:30
committed by GitHub
parent 78bc6afa67
commit 12aaba8a05
11 changed files with 810 additions and 40 deletions
+8 -2
View File
@@ -91,8 +91,8 @@ PMG supports the following package managers:
| `bun` | ✅ Active | `pmg bun add <package>` |
| `pip` | ✅ Active | `pmg pip install <package>` |
| `uv` | ✅ Active | `pmg uv add <package>` or `pmg uv pip install <package>`|
| `poetry` | ✅ Active | `pmg poetry add <package>` |
| `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!
@@ -174,6 +174,8 @@ pip install <package-name>
uv add <package-name>
uv pip install <package-name>
poetry add <package-name>
```
### Alternative: Manual Commands
@@ -188,6 +190,8 @@ pmg pip install <package-name>
pmg uv add <package-name>
pmg uv pip install <package-name>
pmg poetry add <package-name>
```
### Lockfile Installation
@@ -203,6 +207,8 @@ pip install -r requirements.txt # Uses requirements file
uv sync # Installs packages from uv.lock
uv pip sync requirements.txt # Sync from requirements file
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.
@@ -290,7 +296,7 @@ is malicious. However, there is a possibility of inconsistency when a specific v
<details>
<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
malware detection. This limitation applies to direct installations and transitive dependencies sourced
from non-PyPI locations.
+60
View File
@@ -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)
}
+3
View File
@@ -12,6 +12,7 @@ import (
"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/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/uvlock"
"github.com/google/osv-scalibr/fs"
@@ -34,6 +35,8 @@ func getExtractorForFile(filename string) (filesystem.Extractor, error) {
return requirements.NewDefault(), nil
case filename == "uv.lock":
return uvlock.New(), nil
case filename == "poetry.lock":
return poetrylock.New(), nil
default:
return nil, fmt.Errorf("unsupported lockfile type: %s", filename)
}
+12 -10
View File
@@ -22,11 +22,12 @@ type PackageManagerExtractor interface {
type PackageManagerName string
const (
Npm PackageManagerName = "npm"
Pnpm PackageManagerName = "pnpm"
Pip PackageManagerName = "pip"
Bun PackageManagerName = "bun"
Uv PackageManagerName = "uv"
Npm PackageManagerName = "npm"
Pnpm PackageManagerName = "pnpm"
Pip PackageManagerName = "pip"
Bun PackageManagerName = "bun"
Uv PackageManagerName = "uv"
Poetry PackageManagerName = "poetry"
)
type ExtractorManager struct {
@@ -36,11 +37,12 @@ type ExtractorManager struct {
func NewExtractorManager() *ExtractorManager {
return &ExtractorManager{
extractors: map[PackageManagerName]PackageManagerExtractor{
Npm: &NpmExtractor{},
Pnpm: &PnpmExtractor{},
Pip: &PipExtractor{},
Bun: &BunExtractor{},
Uv: &UvExtractor{},
Npm: &NpmExtractor{},
Pnpm: &PnpmExtractor{},
Pip: &PipExtractor{},
Bun: &BunExtractor{},
Uv: &UvExtractor{},
Poetry: &PoetryExtractor{},
},
}
}
+19
View File
@@ -41,3 +41,22 @@ func (u *UvExtractor) GetPackageManager() PackageManagerName {
func (u *UvExtractor) Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
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())
}
+11 -6
View File
@@ -1,12 +1,13 @@
package analytics
const (
eventRun = "pmg_command_run"
eventCommandNpm = "pmg_command_npm"
eventCommandBun = "pmg_command_bun"
eventCommandPnpm = "pmg_command_pnpm"
eventCommandPip = "pmg_command_pip"
eventCommandUv = "pmg_command_uv"
eventRun = "pmg_command_run"
eventCommandNpm = "pmg_command_npm"
eventCommandBun = "pmg_command_bun"
eventCommandPnpm = "pmg_command_pnpm"
eventCommandPip = "pmg_command_pip"
eventCommandUv = "pmg_command_uv"
eventCommandPoetry = "pmg_command_poetry"
eventPmgGenerateEnvDocker = "pmg_command_generate_env_docker"
eventPmgGenerateEnvGitHubActions = "pmg_command_generate_env_github_actions"
@@ -37,6 +38,10 @@ func TrackCommandUv() {
TrackEvent(eventCommandUv)
}
func TrackCommandPoetry() {
TrackEvent(eventCommandPoetry)
}
func TrackCommandGenerateEnvDocker() {
TrackEvent(eventPmgGenerateEnvDocker)
}
+1
View File
@@ -94,6 +94,7 @@ func main() {
cmd.AddCommand(npm.NewBunCommand())
cmd.AddCommand(pypi.NewPipCommand())
cmd.AddCommand(pypi.NewUvCommand())
cmd.AddCommand(pypi.NewPoetryCommand())
cmd.AddCommand(version.NewVersionCommand())
cmd.AddCommand(setup.NewSetupCommand())
cmd.AddCommand(setup.NewRemoveCommand())
+289 -19
View File
@@ -3,6 +3,7 @@ package packagemanager
import (
"fmt"
"io"
"regexp"
"slices"
"strconv"
"strings"
@@ -35,6 +36,13 @@ func DefaultUvPackageManagerConfig() PypiPackageManagerConfig {
}
}
func DefaultPoetryPackageManagerConfig() PypiPackageManagerConfig {
return PypiPackageManagerConfig{
InstallCommands: []string{"add"},
CommandName: "poetry",
}
}
type pypiPackageManager struct {
Config PypiPackageManagerConfig
parser pypiCommandParser
@@ -48,6 +56,8 @@ func NewPypiPackageManager(config PypiPackageManagerConfig) (*pypiPackageManager
parser = NewPipCommandParser(config)
case "uv":
parser = NewUVCommandParser(config)
case "poetry":
parser = NewPoetryCommandParser(config)
default:
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)
}
if version != "" {
if strings.HasPrefix(version, "==") {
version = strings.TrimPrefix(version, "==")
} else {
version, err = pypiGetMatchingVersion(packageName, version)
if err != nil {
return nil, ErrFailedToResolveVersion.Wrap(err)
}
}
version, err = pypiGetMatchingVersion(packageName, version)
if err != nil {
return nil, ErrFailedToResolveVersion.Wrap(err)
}
installTargets = append(installTargets, &PackageInstallTarget{
@@ -262,15 +266,9 @@ func (u *uvCommandParser) ParseCommand(args []string) (*ParsedCommand, error) {
return nil, ErrFailedToParsePackage.Wrap(err)
}
if version != "" {
if strings.HasPrefix(version, "==") {
version = strings.TrimPrefix(version, "==")
} else {
version, err = pypiGetMatchingVersion(packageName, version)
if err != nil {
return nil, ErrFailedToResolveVersion.Wrap(err)
}
}
version, err = pypiGetMatchingVersion(packageName, version)
if err != nil {
return nil, ErrFailedToResolveVersion.Wrap(err)
}
installTargets = append(installTargets, &PackageInstallTarget{
@@ -293,6 +291,103 @@ func (u *uvCommandParser) ParseCommand(args []string) (*ParsedCommand, error) {
}, 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,
// version constraints, and any extras (additional features) to be installed.
// 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
}
// 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
@@ -390,7 +620,47 @@ func pypiConvertCompatibleRelease(version string) string {
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
}
}
+3 -2
View File
@@ -249,14 +249,15 @@ func pypiParseDependency(input string) (string, string, string) {
func pypiGetMatchingVersion(packageName, versionConstraint string) (string, error) {
// Already a exact version
if strings.HasPrefix(versionConstraint, "==") {
return versionConstraint, nil
if after, ok := strings.CutPrefix(versionConstraint, "=="); ok {
return after, nil
}
// Handle compatible release operator
if strings.HasPrefix(versionConstraint, "~=") {
versionConstraint = pypiConvertCompatibleRelease(versionConstraint)
}
// Handle empty version constraint
if versionConstraint == "" {
// Get latest version
+1 -1
View File
@@ -64,7 +64,7 @@ func TestPipGetLatestMatchingVersion(t *testing.T) {
versionConstraint: "==2.28.0",
assertFn: func(t *testing.T, version string, err error) {
require.NoError(t, err)
require.Equal(t, "==2.28.0", version)
require.Equal(t, "2.28.0", version)
},
},
{
+403
View File
@@ -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) {
pm, err := NewPypiPackageManager(DefaultUvPackageManagerConfig())
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)
})
}
}