Skip to content

Commit fd7d837

Browse files
authored
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
1 parent 2cbb24b commit fd7d837

File tree

14 files changed

+478
-197
lines changed

14 files changed

+478
-197
lines changed

README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ npm install <package-name>
4343
```
4444

4545
```shell
46-
pnpm add <package-name>
46+
uv pip install <package-name>
4747
```
4848

4949
## 📑 Table of Contents
@@ -90,7 +90,7 @@ PMG supports the following package managers:
9090
| `pnpm` | ✅ Active | `pmg pnpm add <package>` |
9191
| `bun` | ✅ Active | `pmg bun add <package>` |
9292
| `pip` | ✅ Active | `pmg pip install <package>` |
93-
| `uv` | 🚧 Planned | |
93+
| `uv` | ✅ Active | `pmg uv add <package>` or `pmg uv pip install <package>`|
9494
| `yarn` | 🚧 Planned | |
9595
| `poetry` | 🚧 Planned | |
9696

@@ -169,7 +169,11 @@ After setup, use your package managers normally:
169169
npm install <package-name>
170170
pnpm add <package-name>
171171
bun add <package-name>
172+
172173
pip install <package-name>
174+
175+
uv add <package-name>
176+
uv pip install <package-name>
173177
```
174178

175179
### Alternative: Manual Commands
@@ -179,7 +183,11 @@ You can also run PMG manually without aliases:
179183
pmg npm install <package-name>
180184
pmg pnpm add <package-name>
181185
pmg bun add <package-name>
186+
182187
pmg pip install <package-name>
188+
189+
pmg uv add <package-name>
190+
pmg uv pip install <package-name>
183191
```
184192

185193
### Lockfile Installation
@@ -191,6 +199,10 @@ pnpm install # Uses pnpm-lock.yaml
191199
bun install # Uses bun.lock
192200

193201
pip install -r requirements.txt # Uses requirements file
202+
203+
uv sync # Installs packages from uv.lock
204+
uv pip sync requirements.txt # Sync from requirements file
205+
uv pip install -r requirements.txt
194206
```
195207

196208
PMG scans the exact package versions specified in lockfiles and blocks installation if malicious packages are detected.

cmd/pypi/pip.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func NewPipCommand() *cobra.Command {
3131

3232
func executePipFlow(ctx context.Context, args []string) error {
3333
analytics.TrackCommandPip()
34-
packageManager, err := packagemanager.NewPipPackageManager(packagemanager.DefaultPipPackageManagerConfig())
34+
packageManager, err := packagemanager.NewPypiPackageManager(packagemanager.DefaultPipPackageManagerConfig())
3535
if err != nil {
3636
return fmt.Errorf("failed to create pip package manager: %w", err)
3737
}

cmd/pypi/uv.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package pypi
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/safedep/dry/log"
8+
"github.com/safedep/pmg/config"
9+
"github.com/safedep/pmg/internal/analytics"
10+
"github.com/safedep/pmg/internal/flows"
11+
"github.com/safedep/pmg/internal/ui"
12+
"github.com/safedep/pmg/packagemanager"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
func NewUvCommand() *cobra.Command {
17+
return &cobra.Command{
18+
Use: "uv [action] [package]",
19+
Short: "Guard uv package manager",
20+
DisableFlagParsing: true,
21+
RunE: func(cmd *cobra.Command, args []string) error {
22+
err := executeUvFlow(cmd.Context(), args)
23+
if err != nil {
24+
log.Errorf("Failed to execute uv flow: %s", err)
25+
}
26+
27+
return nil
28+
},
29+
}
30+
}
31+
32+
func executeUvFlow(ctx context.Context, args []string) error {
33+
analytics.TrackCommandUv()
34+
packageManager, err := packagemanager.NewPypiPackageManager(packagemanager.DefaultUvPackageManagerConfig())
35+
if err != nil {
36+
return fmt.Errorf("failed to create uv package manager: %w", err)
37+
}
38+
39+
config, err := config.FromContext(ctx)
40+
if err != nil {
41+
ui.Fatalf("Failed to get config: %s", err)
42+
}
43+
44+
parsedCommand, err := packageManager.ParseCommand(args)
45+
if err != nil {
46+
return fmt.Errorf("failed to parse command: %w", err)
47+
}
48+
49+
// Parse the args right here
50+
packageResolverConfig := packagemanager.NewDefaultPypiDependencyResolverConfig()
51+
packageResolverConfig.IncludeTransitiveDependencies = config.Transitive
52+
packageResolverConfig.TransitiveDepth = config.TransitiveDepth
53+
packageResolverConfig.IncludeDevDependencies = config.IncludeDevDependencies
54+
packageResolverConfig.PackageInstallTargets = parsedCommand.InstallTargets
55+
56+
packageResolver, err := packagemanager.NewPypiDependencyResolver(packageResolverConfig)
57+
if err != nil {
58+
ui.Fatalf("Failed to create dependency resolver: %s", err)
59+
}
60+
61+
return flows.Common(packageManager, packageResolver, config).Run(ctx, args, parsedCommand)
62+
}

extractor/common.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package extractor
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"regexp"
9+
10+
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
11+
"github.com/google/osv-scalibr/extractor/filesystem"
12+
"github.com/google/osv-scalibr/extractor/filesystem/language/javascript/bunlock"
13+
"github.com/google/osv-scalibr/extractor/filesystem/language/javascript/packagelockjson"
14+
"github.com/google/osv-scalibr/extractor/filesystem/language/javascript/pnpmlock"
15+
"github.com/google/osv-scalibr/extractor/filesystem/language/python/requirements"
16+
"github.com/google/osv-scalibr/extractor/filesystem/language/python/uvlock"
17+
"github.com/google/osv-scalibr/fs"
18+
)
19+
20+
func getExtractorForFile(filename string) (filesystem.Extractor, error) {
21+
filename = filepath.Base(filename)
22+
23+
// Regex for requirements files (match requirements.txt and requirements-{word}.txt)
24+
reqPattern := regexp.MustCompile(`^requirements(?:-\w+)?\.txt$`)
25+
26+
switch {
27+
case filename == "package-lock.json":
28+
return packagelockjson.NewDefault(), nil
29+
case filename == "pnpm-lock.yaml":
30+
return pnpmlock.New(), nil
31+
case filename == "bun.lock":
32+
return bunlock.New(), nil
33+
case reqPattern.MatchString(filename):
34+
return requirements.NewDefault(), nil
35+
case filename == "uv.lock":
36+
return uvlock.New(), nil
37+
default:
38+
return nil, fmt.Errorf("unsupported lockfile type: %s", filename)
39+
}
40+
}
41+
42+
func parseLockfile(lockfilePath, scanDir string, ecosystem packagev1.Ecosystem) ([]*packagev1.PackageVersion, error) {
43+
extractor, err := getExtractorForFile(lockfilePath)
44+
if err != nil {
45+
return nil, err
46+
}
47+
48+
file, err := os.Open(lockfilePath)
49+
if err != nil {
50+
return nil, fmt.Errorf("failed to open lockfile: %w", err)
51+
}
52+
defer file.Close()
53+
54+
inputConfig := &filesystem.ScanInput{
55+
FS: fs.DirFS(scanDir),
56+
Path: lockfilePath,
57+
Reader: file,
58+
}
59+
60+
inventory, err := extractor.Extract(context.Background(), inputConfig)
61+
if err != nil {
62+
return nil, fmt.Errorf("failed to extract packages: %w", err)
63+
}
64+
65+
var packages []*packagev1.PackageVersion
66+
67+
for _, invPkg := range inventory.Packages {
68+
pkg := &packagev1.PackageVersion{
69+
Package: &packagev1.Package{
70+
Name: invPkg.Name,
71+
Ecosystem: ecosystem,
72+
},
73+
Version: invPkg.Version,
74+
}
75+
76+
packages = append(packages, pkg)
77+
}
78+
79+
return packages, nil
80+
}

extractor/ecosystems.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const (
2626
Pnpm PackageManagerName = "pnpm"
2727
Pip PackageManagerName = "pip"
2828
Bun PackageManagerName = "bun"
29+
Uv PackageManagerName = "uv"
2930
)
3031

3132
type ExtractorManager struct {
@@ -39,6 +40,7 @@ func NewExtractorManager() *ExtractorManager {
3940
Pnpm: &PnpmExtractor{},
4041
Pip: &PipExtractor{},
4142
Bun: &BunExtractor{},
43+
Uv: &UvExtractor{},
4244
},
4345
}
4446
}

extractor/npm.go

Lines changed: 3 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,7 @@
11
package extractor
22

33
import (
4-
"context"
5-
"fmt"
6-
"os"
7-
84
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
9-
"github.com/google/osv-scalibr/extractor/filesystem"
10-
"github.com/google/osv-scalibr/extractor/filesystem/language/javascript/bunlock"
11-
"github.com/google/osv-scalibr/extractor/filesystem/language/javascript/packagelockjson"
12-
"github.com/google/osv-scalibr/extractor/filesystem/language/javascript/pnpmlock"
13-
"github.com/google/osv-scalibr/fs"
145
)
156

167
// NpmExtractor handles package-lock.json files
@@ -29,44 +20,7 @@ func (n *NpmExtractor) GetPackageManager() PackageManagerName {
2920
}
3021

3122
func (n *NpmExtractor) Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
32-
return parseNpmPackageLockFile(lockfilePath, scanDir)
33-
}
34-
35-
func parseNpmPackageLockFile(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
36-
packagelockExtractor := packagelockjson.NewDefault()
37-
38-
file, err := os.Open(lockfilePath)
39-
if err != nil {
40-
return nil, fmt.Errorf("failed to open lockfile: %w", err)
41-
}
42-
defer file.Close()
43-
44-
inputConfig := &filesystem.ScanInput{
45-
FS: fs.DirFS(scanDir),
46-
Path: lockfilePath,
47-
Reader: file,
48-
}
49-
50-
inventory, err := packagelockExtractor.Extract(context.Background(), inputConfig)
51-
if err != nil {
52-
return nil, fmt.Errorf("failed to extract packages: %w", err)
53-
}
54-
55-
var packages []*packagev1.PackageVersion
56-
57-
for _, invPkg := range inventory.Packages {
58-
pkg := &packagev1.PackageVersion{
59-
Package: &packagev1.Package{
60-
Name: invPkg.Name,
61-
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
62-
},
63-
Version: invPkg.Version,
64-
}
65-
66-
packages = append(packages, pkg)
67-
}
68-
69-
return packages, nil
23+
return parseLockfile(lockfilePath, scanDir, n.GetEcosystem())
7024
}
7125

7226
// PnpmExtractor handles pnpm-lock.yaml files
@@ -85,44 +39,7 @@ func (p *PnpmExtractor) GetPackageManager() PackageManagerName {
8539
}
8640

8741
func (p *PnpmExtractor) Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
88-
return parsePnpmLockFile(lockfilePath, scanDir)
89-
}
90-
91-
func parsePnpmLockFile(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
92-
pnpmLockExtractor := pnpmlock.New()
93-
94-
file, err := os.Open(lockfilePath)
95-
if err != nil {
96-
return nil, fmt.Errorf("failed to open lockfile: %w", err)
97-
}
98-
defer file.Close()
99-
100-
inputConfig := &filesystem.ScanInput{
101-
FS: fs.DirFS(scanDir),
102-
Path: lockfilePath,
103-
Reader: file,
104-
}
105-
106-
inventory, err := pnpmLockExtractor.Extract(context.Background(), inputConfig)
107-
if err != nil {
108-
return nil, fmt.Errorf("failed to extract packages: %w", err)
109-
}
110-
111-
var packages []*packagev1.PackageVersion
112-
113-
for _, invPkg := range inventory.Packages {
114-
pkg := &packagev1.PackageVersion{
115-
Package: &packagev1.Package{
116-
Name: invPkg.Name,
117-
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
118-
},
119-
Version: invPkg.Version,
120-
}
121-
122-
packages = append(packages, pkg)
123-
}
124-
125-
return packages, nil
42+
return parseLockfile(lockfilePath, scanDir, p.GetEcosystem())
12643
}
12744

12845
type BunExtractor struct{}
@@ -140,42 +57,5 @@ func (n *BunExtractor) GetPackageManager() PackageManagerName {
14057
}
14158

14259
func (n *BunExtractor) Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
143-
return parseBunPackageLockFile(lockfilePath, scanDir)
144-
}
145-
146-
func parseBunPackageLockFile(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
147-
bunlockExtractor := bunlock.New()
148-
149-
file, err := os.Open(lockfilePath)
150-
if err != nil {
151-
return nil, fmt.Errorf("failed to open lockfile: %w", err)
152-
}
153-
defer file.Close()
154-
155-
inputConfig := &filesystem.ScanInput{
156-
FS: fs.DirFS(scanDir),
157-
Path: lockfilePath,
158-
Reader: file,
159-
}
160-
161-
inventory, err := bunlockExtractor.Extract(context.Background(), inputConfig)
162-
if err != nil {
163-
return nil, fmt.Errorf("failed to extract packages: %w", err)
164-
}
165-
166-
var packages []*packagev1.PackageVersion
167-
168-
for _, invPkg := range inventory.Packages {
169-
pkg := &packagev1.PackageVersion{
170-
Package: &packagev1.Package{
171-
Name: invPkg.Name,
172-
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
173-
},
174-
Version: invPkg.Version,
175-
}
176-
177-
packages = append(packages, pkg)
178-
}
179-
180-
return packages, nil
60+
return parseLockfile(lockfilePath, scanDir, n.GetEcosystem())
18161
}

0 commit comments

Comments
 (0)