mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
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:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user