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
+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)
})
}
}