feat/#28 uv support (#62)

* follow proper consistent naming in pypi packagemanager

* follow proper consistent naming in pypi packagemanager - 2

* feat: add specialized command parsers to handle pip and uv command formats

* add uv support & modify extractor to be more robust

* add uv alias

* refactor var name & add error handling

* update readme & add support for `uv pip sync` cmd
This commit is contained in:
Sahil Bansal
2025-08-06 21:10:51 +05:30
committed by GitHub
parent 2cbb24b3b1
commit fd7d83704f
14 changed files with 478 additions and 197 deletions
+14 -2
View File
@@ -43,7 +43,7 @@ npm install <package-name>
```
```shell
pnpm add <package-name>
uv pip install <package-name>
```
## 📑 Table of Contents
@@ -90,7 +90,7 @@ PMG supports the following package managers:
| `pnpm` | ✅ Active | `pmg pnpm add <package>` |
| `bun` | ✅ Active | `pmg bun add <package>` |
| `pip` | ✅ Active | `pmg pip install <package>` |
| `uv` | 🚧 Planned | |
| `uv` | ✅ Active | `pmg uv add <package>` or `pmg uv pip install <package>`|
| `yarn` | 🚧 Planned | |
| `poetry` | 🚧 Planned | |
@@ -169,7 +169,11 @@ After setup, use your package managers normally:
npm install <package-name>
pnpm add <package-name>
bun add <package-name>
pip install <package-name>
uv add <package-name>
uv pip install <package-name>
```
### Alternative: Manual Commands
@@ -179,7 +183,11 @@ You can also run PMG manually without aliases:
pmg npm install <package-name>
pmg pnpm add <package-name>
pmg bun add <package-name>
pmg pip install <package-name>
pmg uv add <package-name>
pmg uv pip install <package-name>
```
### Lockfile Installation
@@ -191,6 +199,10 @@ pnpm install # Uses pnpm-lock.yaml
bun install # Uses bun.lock
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
```
PMG scans the exact package versions specified in lockfiles and blocks installation if malicious packages are detected.
+1 -1
View File
@@ -31,7 +31,7 @@ func NewPipCommand() *cobra.Command {
func executePipFlow(ctx context.Context, args []string) error {
analytics.TrackCommandPip()
packageManager, err := packagemanager.NewPipPackageManager(packagemanager.DefaultPipPackageManagerConfig())
packageManager, err := packagemanager.NewPypiPackageManager(packagemanager.DefaultPipPackageManagerConfig())
if err != nil {
return fmt.Errorf("failed to create pip package manager: %w", err)
}
+62
View File
@@ -0,0 +1,62 @@
package pypi
import (
"context"
"fmt"
"github.com/safedep/dry/log"
"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 NewUvCommand() *cobra.Command {
return &cobra.Command{
Use: "uv [action] [package]",
Short: "Guard uv package manager",
DisableFlagParsing: true,
RunE: func(cmd *cobra.Command, args []string) error {
err := executeUvFlow(cmd.Context(), args)
if err != nil {
log.Errorf("Failed to execute uv flow: %s", err)
}
return nil
},
}
}
func executeUvFlow(ctx context.Context, args []string) error {
analytics.TrackCommandUv()
packageManager, err := packagemanager.NewPypiPackageManager(packagemanager.DefaultUvPackageManagerConfig())
if err != nil {
return fmt.Errorf("failed to create uv package manager: %w", err)
}
config, err := config.FromContext(ctx)
if err != nil {
ui.Fatalf("Failed to get config: %s", err)
}
parsedCommand, err := packageManager.ParseCommand(args)
if err != nil {
return fmt.Errorf("failed to parse command: %w", err)
}
// Parse the args right here
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 {
ui.Fatalf("Failed to create dependency resolver: %s", err)
}
return flows.Common(packageManager, packageResolver, config).Run(ctx, args, parsedCommand)
}
+80
View File
@@ -0,0 +1,80 @@
package extractor
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/google/osv-scalibr/extractor/filesystem"
"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/requirements"
"github.com/google/osv-scalibr/extractor/filesystem/language/python/uvlock"
"github.com/google/osv-scalibr/fs"
)
func getExtractorForFile(filename string) (filesystem.Extractor, error) {
filename = filepath.Base(filename)
// Regex for requirements files (match requirements.txt and requirements-{word}.txt)
reqPattern := regexp.MustCompile(`^requirements(?:-\w+)?\.txt$`)
switch {
case filename == "package-lock.json":
return packagelockjson.NewDefault(), nil
case filename == "pnpm-lock.yaml":
return pnpmlock.New(), nil
case filename == "bun.lock":
return bunlock.New(), nil
case reqPattern.MatchString(filename):
return requirements.NewDefault(), nil
case filename == "uv.lock":
return uvlock.New(), nil
default:
return nil, fmt.Errorf("unsupported lockfile type: %s", filename)
}
}
func parseLockfile(lockfilePath, scanDir string, ecosystem packagev1.Ecosystem) ([]*packagev1.PackageVersion, error) {
extractor, err := getExtractorForFile(lockfilePath)
if err != nil {
return nil, err
}
file, err := os.Open(lockfilePath)
if err != nil {
return nil, fmt.Errorf("failed to open lockfile: %w", err)
}
defer file.Close()
inputConfig := &filesystem.ScanInput{
FS: fs.DirFS(scanDir),
Path: lockfilePath,
Reader: file,
}
inventory, err := extractor.Extract(context.Background(), inputConfig)
if err != nil {
return nil, fmt.Errorf("failed to extract packages: %w", err)
}
var packages []*packagev1.PackageVersion
for _, invPkg := range inventory.Packages {
pkg := &packagev1.PackageVersion{
Package: &packagev1.Package{
Name: invPkg.Name,
Ecosystem: ecosystem,
},
Version: invPkg.Version,
}
packages = append(packages, pkg)
}
return packages, nil
}
+2
View File
@@ -26,6 +26,7 @@ const (
Pnpm PackageManagerName = "pnpm"
Pip PackageManagerName = "pip"
Bun PackageManagerName = "bun"
Uv PackageManagerName = "uv"
)
type ExtractorManager struct {
@@ -39,6 +40,7 @@ func NewExtractorManager() *ExtractorManager {
Pnpm: &PnpmExtractor{},
Pip: &PipExtractor{},
Bun: &BunExtractor{},
Uv: &UvExtractor{},
},
}
}
+3 -123
View File
@@ -1,16 +1,7 @@
package extractor
import (
"context"
"fmt"
"os"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/google/osv-scalibr/extractor/filesystem"
"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/fs"
)
// NpmExtractor handles package-lock.json files
@@ -29,44 +20,7 @@ func (n *NpmExtractor) GetPackageManager() PackageManagerName {
}
func (n *NpmExtractor) Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
return parseNpmPackageLockFile(lockfilePath, scanDir)
}
func parseNpmPackageLockFile(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
packagelockExtractor := packagelockjson.NewDefault()
file, err := os.Open(lockfilePath)
if err != nil {
return nil, fmt.Errorf("failed to open lockfile: %w", err)
}
defer file.Close()
inputConfig := &filesystem.ScanInput{
FS: fs.DirFS(scanDir),
Path: lockfilePath,
Reader: file,
}
inventory, err := packagelockExtractor.Extract(context.Background(), inputConfig)
if err != nil {
return nil, fmt.Errorf("failed to extract packages: %w", err)
}
var packages []*packagev1.PackageVersion
for _, invPkg := range inventory.Packages {
pkg := &packagev1.PackageVersion{
Package: &packagev1.Package{
Name: invPkg.Name,
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
},
Version: invPkg.Version,
}
packages = append(packages, pkg)
}
return packages, nil
return parseLockfile(lockfilePath, scanDir, n.GetEcosystem())
}
// PnpmExtractor handles pnpm-lock.yaml files
@@ -85,44 +39,7 @@ func (p *PnpmExtractor) GetPackageManager() PackageManagerName {
}
func (p *PnpmExtractor) Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
return parsePnpmLockFile(lockfilePath, scanDir)
}
func parsePnpmLockFile(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
pnpmLockExtractor := pnpmlock.New()
file, err := os.Open(lockfilePath)
if err != nil {
return nil, fmt.Errorf("failed to open lockfile: %w", err)
}
defer file.Close()
inputConfig := &filesystem.ScanInput{
FS: fs.DirFS(scanDir),
Path: lockfilePath,
Reader: file,
}
inventory, err := pnpmLockExtractor.Extract(context.Background(), inputConfig)
if err != nil {
return nil, fmt.Errorf("failed to extract packages: %w", err)
}
var packages []*packagev1.PackageVersion
for _, invPkg := range inventory.Packages {
pkg := &packagev1.PackageVersion{
Package: &packagev1.Package{
Name: invPkg.Name,
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
},
Version: invPkg.Version,
}
packages = append(packages, pkg)
}
return packages, nil
return parseLockfile(lockfilePath, scanDir, p.GetEcosystem())
}
type BunExtractor struct{}
@@ -140,42 +57,5 @@ func (n *BunExtractor) GetPackageManager() PackageManagerName {
}
func (n *BunExtractor) Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
return parseBunPackageLockFile(lockfilePath, scanDir)
}
func parseBunPackageLockFile(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
bunlockExtractor := bunlock.New()
file, err := os.Open(lockfilePath)
if err != nil {
return nil, fmt.Errorf("failed to open lockfile: %w", err)
}
defer file.Close()
inputConfig := &filesystem.ScanInput{
FS: fs.DirFS(scanDir),
Path: lockfilePath,
Reader: file,
}
inventory, err := bunlockExtractor.Extract(context.Background(), inputConfig)
if err != nil {
return nil, fmt.Errorf("failed to extract packages: %w", err)
}
var packages []*packagev1.PackageVersion
for _, invPkg := range inventory.Packages {
pkg := &packagev1.PackageVersion{
Package: &packagev1.Package{
Name: invPkg.Name,
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
},
Version: invPkg.Version,
}
packages = append(packages, pkg)
}
return packages, nil
return parseLockfile(lockfilePath, scanDir, n.GetEcosystem())
}
+14 -39
View File
@@ -1,14 +1,7 @@
package extractor
import (
"context"
"fmt"
"os"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/google/osv-scalibr/extractor/filesystem"
"github.com/google/osv-scalibr/extractor/filesystem/language/python/requirements"
"github.com/google/osv-scalibr/fs"
)
// PipExtractor handles requirements.txt files
@@ -27,42 +20,24 @@ func (p *PipExtractor) GetPackageManager() PackageManagerName {
}
func (n *PipExtractor) Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
return parseRequirementsTxtFile(lockfilePath, scanDir)
return parseLockfile(lockfilePath, scanDir, n.GetEcosystem())
}
func parseRequirementsTxtFile(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
requirementsExtractor := requirements.NewDefault()
// UvExtractor handles uv.lock files
type UvExtractor struct{}
file, err := os.Open(lockfilePath)
if err != nil {
return nil, fmt.Errorf("failed to open lockfile: %w", err)
}
defer file.Close()
func (u *UvExtractor) GetSupportedFiles() []string {
return []string{"uv.lock"}
}
inputConfig := &filesystem.ScanInput{
FS: fs.DirFS(scanDir),
Path: lockfilePath,
Reader: file,
}
func (u *UvExtractor) GetEcosystem() packagev1.Ecosystem {
return packagev1.Ecosystem_ECOSYSTEM_PYPI
}
inventory, err := requirementsExtractor.Extract(context.Background(), inputConfig)
if err != nil {
return nil, fmt.Errorf("failed to extract packages: %w", err)
}
func (u *UvExtractor) GetPackageManager() PackageManagerName {
return Uv
}
var packages []*packagev1.PackageVersion
for _, invPkg := range inventory.Packages {
pkg := &packagev1.PackageVersion{
Package: &packagev1.Package{
Name: invPkg.Name,
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_PYPI,
},
Version: invPkg.Version,
}
packages = append(packages, pkg)
}
return packages, nil
func (u *UvExtractor) Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
return parseLockfile(lockfilePath, scanDir, u.GetEcosystem())
}
+1 -1
View File
@@ -92,7 +92,7 @@ func DefaultConfig() *AliasConfig {
return &AliasConfig{
RcFileName: ".pmg.rc",
PackageManagers: []string{"npm", "pip", "pnpm", "bun"},
PackageManagers: []string{"npm", "pip", "pnpm", "bun", "uv"},
Shells: shells,
}
}
+5
View File
@@ -6,6 +6,7 @@ const (
eventCommandBun = "pmg_command_bun"
eventCommandPnpm = "pmg_command_pnpm"
eventCommandPip = "pmg_command_pip"
eventCommandUv = "pmg_command_uv"
eventPmgGenerateEnvDocker = "pmg_command_generate_env_docker"
eventPmgGenerateEnvGitHubActions = "pmg_command_generate_env_github_actions"
@@ -32,6 +33,10 @@ func TrackCommandPip() {
TrackEvent(eventCommandPip)
}
func TrackCommandUv() {
TrackEvent(eventCommandUv)
}
func TrackCommandGenerateEnvDocker() {
TrackEvent(eventPmgGenerateEnvDocker)
}
+1
View File
@@ -92,6 +92,7 @@ func main() {
cmd.AddCommand(npm.NewPnpmCommand())
cmd.AddCommand(npm.NewBunCommand())
cmd.AddCommand(pypi.NewPipCommand())
cmd.AddCommand(pypi.NewUvCommand())
cmd.AddCommand(version.NewVersionCommand())
cmd.AddCommand(setup.NewSetupCommand())
cmd.AddCommand(setup.NewRemoveCommand())
+178 -24
View File
@@ -12,45 +12,83 @@ import (
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
)
type PipPackageManagerConfig struct {
type pypiCommandParser interface {
ParseCommand(args []string) (*ParsedCommand, error)
}
type PypiPackageManagerConfig struct {
InstallCommands []string
CommandName string
}
func DefaultPipPackageManagerConfig() PipPackageManagerConfig {
return PipPackageManagerConfig{
func DefaultPipPackageManagerConfig() PypiPackageManagerConfig {
return PypiPackageManagerConfig{
InstallCommands: []string{"install"},
CommandName: "pip",
}
}
type pipPackageManager struct {
Config PipPackageManagerConfig
func DefaultUvPackageManagerConfig() PypiPackageManagerConfig {
return PypiPackageManagerConfig{
InstallCommands: []string{"add", "install"},
CommandName: "uv",
}
}
func NewPipPackageManager(config PipPackageManagerConfig) (*pipPackageManager, error) {
return &pipPackageManager{
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)
default:
return nil, fmt.Errorf("unsupported package manager: %s", config.CommandName)
}
return &pypiPackageManager{
Config: config,
parser: parser,
}, nil
}
var _ PackageManager = &pipPackageManager{}
var _ PackageManager = &pypiPackageManager{}
func (pip *pipPackageManager) Name() string {
return "pip"
func (pypi *pypiPackageManager) Name() string {
return pypi.Config.CommandName
}
func (pip *pipPackageManager) Ecosystem() packagev1.Ecosystem {
func (pypi *pypiPackageManager) Ecosystem() packagev1.Ecosystem {
return packagev1.Ecosystem_ECOSYSTEM_PYPI
}
func (pip *pipPackageManager) ParseCommand(args []string) (*ParsedCommand, error) {
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 = args[1:]
}
command := Command{Exe: pip.Config.CommandName, Args: args}
command := Command{Exe: p.config.CommandName, Args: args}
if len(args) < 1 {
return &ParsedCommand{Command: command}, nil
@@ -59,7 +97,7 @@ func (pip *pipPackageManager) ParseCommand(args []string) (*ParsedCommand, error
// Find the install command
var installCmdIndex = -1
for idx, arg := range args {
if slices.Contains(pip.Config.InstallCommands, arg) {
if slices.Contains(p.config.InstallCommands, arg) {
installCmdIndex = idx
break
}
@@ -73,15 +111,15 @@ func (pip *pipPackageManager) ParseCommand(args []string) (*ParsedCommand, error
// Extract arguments after the install command
installArgs := args[installCmdIndex+1:]
fs := pflag.NewFlagSet("pip", pflag.ContinueOnError)
fs.SetOutput(io.Discard)
flagSet := pflag.NewFlagSet("pip", pflag.ContinueOnError)
flagSet.SetOutput(io.Discard)
// Define flags
var requirementFiles []string
fs.StringArrayVarP(&requirementFiles, "requirement", "r", nil, "Install from requirement file")
flagSet.StringArrayVarP(&requirementFiles, "requirement", "r", nil, "Install from requirement file")
// Parse arguments (supports interleaved flags + positional args)
err := fs.Parse(installArgs)
err := flagSet.Parse(installArgs)
if err != nil {
return &ParsedCommand{
Command: command,
@@ -89,7 +127,7 @@ func (pip *pipPackageManager) ParseCommand(args []string) (*ParsedCommand, error
}
// Get remaining arguments (package names)
packages := fs.Args()
packages := flagSet.Args()
// Determine if this is a manifest install
isManifestInstall := len(requirementFiles) > 0
@@ -101,7 +139,7 @@ func (pip *pipPackageManager) ParseCommand(args []string) (*ParsedCommand, error
// Process packages
var installTargets []*PackageInstallTarget
for _, pkg := range packages {
packageName, version, extras, err := pipParsePackageInfo(pkg)
packageName, version, extras, err := pypiParsePackageInfo(pkg)
if err != nil {
return nil, fmt.Errorf("failed to parse package info: %w", err)
}
@@ -110,7 +148,7 @@ func (pip *pipPackageManager) ParseCommand(args []string) (*ParsedCommand, error
if strings.HasPrefix(version, "==") {
version = strings.TrimPrefix(version, "==")
} else {
version, err = pipGetMatchingVersion(packageName, version)
version, err = pypiGetMatchingVersion(packageName, version)
if err != nil {
return nil, fmt.Errorf("error resolving version for %s: %s", packageName, err.Error())
}
@@ -137,10 +175,126 @@ func (pip *pipPackageManager) ParseCommand(args []string) (*ParsedCommand, error
}, nil
}
// pipParsePackageInfo parses a pip install package specification, separating the package name,
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 {
// 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("uv", pflag.ContinueOnError)
flagSet.SetOutput(io.Discard)
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, fmt.Errorf("failed to parse package info: %w", err)
}
if version != "" {
if strings.HasPrefix(version, "==") {
version = strings.TrimPrefix(version, "==")
} else {
version, err = pypiGetMatchingVersion(packageName, version)
if err != nil {
return nil, fmt.Errorf("error resolving version for %s: %s", packageName, err.Error())
}
}
}
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: isManifestInstall,
ManifestFiles: manifestFiles,
}, 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 pipParsePackageInfo(input string) (packageName, version string, extras []string, err error) {
func pypiParsePackageInfo(input string) (packageName, version string, extras []string, err error) {
if input == "" {
return "", "", nil, fmt.Errorf("package info cannot be empty")
}
@@ -195,7 +349,7 @@ func pipParsePackageInfo(input string) (packageName, version string, extras []st
return packageName, version, extras, nil
}
func pipConvertCompatibleRelease(version string) string {
func pypiConvertCompatibleRelease(version string) string {
if !strings.HasPrefix(version, "~=") {
return version
}
+3 -3
View File
@@ -61,7 +61,7 @@ func NewPypiDependencyResolver(config PyPiDependencyResolverConfig) (*pypiDepend
func (p *pypiDependencyResolver) ResolveDependencies(ctx context.Context, pkg *packagev1.PackageVersion) ([]*packagev1.PackageVersion, error) {
pypiVersionSpecResolverFn := func(packageName, version string) string {
ver, err := pipGetMatchingVersion(packageName, version)
ver, err := pypiGetMatchingVersion(packageName, version)
if err != nil {
log.Debugf("error getting matching version for %s@%s", packageName, version)
return ""
@@ -247,7 +247,7 @@ func pypiParseDependency(input string) (string, string, string) {
return name, version, extra
}
func pipGetMatchingVersion(packageName, versionConstraint string) (string, error) {
func pypiGetMatchingVersion(packageName, versionConstraint string) (string, error) {
// Already a exact version
if strings.HasPrefix(versionConstraint, "==") {
return versionConstraint, nil
@@ -255,7 +255,7 @@ func pipGetMatchingVersion(packageName, versionConstraint string) (string, error
// Handle compatible release operator
if strings.HasPrefix(versionConstraint, "~=") {
versionConstraint = pipConvertCompatibleRelease(versionConstraint)
versionConstraint = pypiConvertCompatibleRelease(versionConstraint)
}
// Handle empty version constraint
if versionConstraint == "" {
+1 -1
View File
@@ -91,7 +91,7 @@ func TestPipGetLatestMatchingVersion(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
version, err := pipGetMatchingVersion(tc.packageName, tc.versionConstraint)
version, err := pypiGetMatchingVersion(tc.packageName, tc.versionConstraint)
tc.assertFn(t, version, err)
})
}
+113 -3
View File
@@ -98,7 +98,7 @@ func TestPipParsePackageInfo(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
pkgName, version, extras, err := pipParsePackageInfo(tc.input)
pkgName, version, extras, err := pypiParsePackageInfo(tc.input)
if tc.wantErr {
assert.Error(t, err)
} else {
@@ -112,7 +112,7 @@ func TestPipParsePackageInfo(t *testing.T) {
}
func TestPipParseCommand(t *testing.T) {
pm, err := NewPipPackageManager(DefaultPipPackageManagerConfig())
pm, err := NewPypiPackageManager(DefaultPipPackageManagerConfig())
assert.NoError(t, err)
cases := []struct {
@@ -245,8 +245,118 @@ func TestPipConvertCompatibleRelease(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result := pipConvertCompatibleRelease(tc.input)
result := pypiConvertCompatibleRelease(tc.input)
assert.Equal(t, tc.expected, result)
})
}
}
func TestUvParseCommand(t *testing.T) {
pm, err := NewPypiPackageManager(DefaultUvPackageManagerConfig())
assert.NoError(t, err)
cases := []struct {
name string
args []string
expectedManifest bool
expectedFiles []string
expectedTargets int
expectedPackages []string
wantErr bool
}{
{
name: "uv add simple package",
args: []string{"add", "flask"},
expectedManifest: false,
expectedFiles: []string{""},
expectedTargets: 1,
expectedPackages: []string{"flask"},
wantErr: false,
},
{
name: "uv add multiple packages",
args: []string{"add", "flask", "requests"},
expectedManifest: false,
expectedFiles: []string{""},
expectedTargets: 2,
expectedPackages: []string{
"flask",
"requests",
},
wantErr: false,
},
{
name: "uv pip install simple package",
args: []string{"pip", "install", "fastapi"},
expectedManifest: false,
expectedFiles: []string{""},
expectedTargets: 2,
expectedPackages: []string{"fastapi"},
wantErr: false,
},
{
name: "uv pip install multiple packages",
args: []string{"pip", "install", "flask", "requests"},
expectedManifest: false,
expectedFiles: []string{""},
expectedTargets: 2,
expectedPackages: []string{
"flask",
"requests",
},
wantErr: false,
},
{
name: "uv pip install from manifest file",
args: []string{"pip", "install", "-r", "requirements.txt"},
expectedManifest: true,
expectedFiles: []string{"requirements.txt"},
expectedTargets: 0,
expectedPackages: []string{},
wantErr: false,
},
{
name: "uv pip install from multiple manifest files",
args: []string{"pip", "install", "-r", "requirements.txt", "-r", "dev-requirements.txt"},
expectedManifest: true,
expectedFiles: []string{"requirements.txt", "dev-requirements.txt"},
expectedTargets: 0,
expectedPackages: []string{},
wantErr: false,
},
{
name: "uv sync",
args: []string{"sync"},
expectedManifest: true,
expectedFiles: []string{"uv.lock"},
expectedTargets: 0,
expectedPackages: []string{},
wantErr: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result, err := pm.ParseCommand(tc.args)
if tc.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tc.expectedManifest, result.HasManifestInstall(), "HasManifestInstall mismatch")
expectedShouldExtract := tc.expectedManifest && tc.expectedTargets == 0
assert.Equal(t, expectedShouldExtract, result.ShouldExtractFromManifest(), "ShouldExtractFromManifest mismatch")
assert.Equal(t, len(tc.expectedPackages), len(result.InstallTargets), "Number of install targets mismatch")
for i, expectedPkg := range tc.expectedPackages {
if i < len(result.InstallTargets) {
target := result.InstallTargets[i]
assert.Equal(t, expectedPkg, target.PackageVersion.Package.Name, "Package name mismatch for package %d", i)
}
}
})
}
}