Skip to content

Commit 628cdf3

Browse files
authored
Merge pull request #51 from snyk/feat/pip-dry-run
feat: add functions to run pip install --dry-run
2 parents 99f31cb + 687e9c7 commit 628cdf3

File tree

21 files changed

+946
-9
lines changed

21 files changed

+946
-9
lines changed

.circleci/config.yml

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,33 +3,61 @@ version: 2.1
33
orbs:
44
prodsec: snyk/prodsec-orb@1
55

6+
parameters:
7+
go_version:
8+
type: string
9+
default: "1.24"
10+
611
metadata:
712
resource_class: small
813
working_directory: ~/<project name>
914

1015
go_image: &go_image
1116
resource_class: medium
1217
docker:
13-
- image: cimg/go:1.24
18+
- image: cimg/go:<< pipeline.parameters.go_version >>
1419

1520
jobs:
1621
lint:
17-
docker:
18-
- image: cimg/go:1.24
22+
<<: *go_image
1923
steps:
2024
- checkout
2125
- run:
2226
name: run lint check
2327
command: make lint
2428
unit_test:
25-
docker:
26-
- image: cimg/go:1.24
29+
<<: *go_image
2730
steps:
2831
- checkout
2932
- run:
3033
name: run unit tests
3134
command: make test
3235

36+
python_integration_test:
37+
parameters:
38+
python_version:
39+
type: string
40+
resource_class: medium
41+
docker:
42+
- image: cimg/python:<< parameters.python_version >>
43+
steps:
44+
- checkout
45+
- run:
46+
name: Install Go
47+
command: |
48+
wget -qO- https://go.dev/dl/go<< pipeline.parameters.go_version >>.0.linux-amd64.tar.gz | sudo tar -C /usr/local -xz
49+
echo 'export PATH=$PATH:/usr/local/go/bin' >> $BASH_ENV
50+
source $BASH_ENV
51+
- run:
52+
name: Verify installations
53+
command: |
54+
go version
55+
python --version
56+
pip --version
57+
- run:
58+
name: Run Python integration tests
59+
command: make test-python-integration
60+
3361
security-scans:
3462
<<: *go_image
3563
resource_class: small
@@ -68,3 +96,14 @@ workflows:
6896
branches:
6997
ignore:
7098
- main
99+
- python_integration_test:
100+
name: Python << matrix.python_version >> integration tests
101+
requires:
102+
- Unit tests
103+
matrix:
104+
parameters:
105+
python_version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
106+
filters:
107+
branches:
108+
ignore:
109+
- main

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,7 @@ tidy:
1616
test:
1717
$(GOTEST) ./... -coverprofile cp.out
1818

19-
.PHONY: install-req fmt test lint tidy imports
19+
test-python-integration:
20+
$(GOTEST) -v -tags="integration,python" -timeout=10m ./pkg/ecosystems/python/pip/ -coverprofile cp.out
21+
22+
.PHONY: install-req fmt test test-python-integration lint tidy imports

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ require (
1010
github.com/snyk/error-catalog-golang-public v0.0.0-20250310083934-7ac627e3451f
1111
github.com/snyk/go-application-framework v0.0.0-20230714085540-37d34ad405bc
1212
github.com/spf13/pflag v1.0.5
13-
github.com/stretchr/testify v1.10.0
13+
github.com/stretchr/testify v1.11.1
1414
)
1515

1616
require (

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
219219
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
220220
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
221221
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
222-
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
223-
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
222+
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
223+
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
224224
github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs=
225225
github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
226226
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
package pip
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"os/exec"
10+
"strings"
11+
12+
"github.com/snyk/error-catalog-golang-public/opensource/ecosystems"
13+
"github.com/snyk/error-catalog-golang-public/snyk"
14+
"github.com/snyk/error-catalog-golang-public/snyk_errors"
15+
)
16+
17+
// Report represents the minimal JSON output from pip install --report
18+
// needed to build a dependency graph.
19+
type Report struct {
20+
Install []InstallItem `json:"install"`
21+
}
22+
23+
// InstallItem represents a single package in the pip install report.
24+
type InstallItem struct {
25+
Metadata PackageMetadata `json:"metadata"`
26+
Requested bool `json:"requested"` // True if explicitly requested in requirements
27+
}
28+
29+
// IsDirectDependency returns true if this package is a direct dependency.
30+
func (item InstallItem) IsDirectDependency() bool {
31+
return item.Requested
32+
}
33+
34+
// PackageMetadata contains the package name, version, and dependencies.
35+
//
36+
//nolint:tagliatelle // requires_dist is the field name used by pip's JSON output
37+
type PackageMetadata struct {
38+
Name string `json:"name"`
39+
Version string `json:"version"`
40+
RequiresDist []string `json:"requires_dist"` // List of dependencies (e.g., "urllib3 (<3,>=1.21.1)")
41+
}
42+
43+
// GetInstallReport runs pip install with --dry-run and --report flags to get
44+
// a JSON report of what would be installed from a requirements file.
45+
// No files are written to disk; the report is captured from stdout.
46+
//
47+
// This is a convenience wrapper around GetInstallReportWithExecutor that uses
48+
// the default executor. For testing, use GetInstallReportWithExecutor directly.
49+
func GetInstallReport(ctx context.Context, requirementsFile string) (*Report, error) {
50+
return GetInstallReportWithExecutor(ctx, requirementsFile, &DefaultExecutor{})
51+
}
52+
53+
// GetInstallReportWithExecutor is a testable version that accepts a CommandExecutor.
54+
// It runs pip install with the following flags:
55+
// - --dry-run: Don't actually install anything
56+
// - --ignore-installed: Show all packages, not just new ones
57+
// - --report -: Output JSON report to stdout (dash means stdout)
58+
// - --quiet: Suppress non-error output (except the report)
59+
// - -r: Read from requirements file
60+
func GetInstallReportWithExecutor(ctx context.Context, requirementsFile string, executor CommandExecutor) (*Report, error) {
61+
if requirementsFile == "" {
62+
return nil, fmt.Errorf("requirements file path cannot be empty")
63+
}
64+
65+
// Build pip command arguments
66+
args := []string{
67+
"install",
68+
"--dry-run",
69+
"--ignore-installed",
70+
"--report", "-",
71+
"--quiet",
72+
"-r", requirementsFile,
73+
}
74+
75+
// Execute the command
76+
output, err := executor.Execute(ctx, "pip", args...)
77+
if err != nil {
78+
return nil, classifyPipError(err)
79+
}
80+
81+
// Parse the JSON report
82+
var report Report
83+
if err := json.Unmarshal(output, &report); err != nil {
84+
return nil, fmt.Errorf("failed to parse pip report: %w", err)
85+
}
86+
87+
return &report, nil
88+
}
89+
90+
// CommandExecutor is an interface for executing commands.
91+
// This allows for dependency injection and easier testing.
92+
type CommandExecutor interface {
93+
Execute(ctx context.Context, name string, args ...string) ([]byte, error)
94+
}
95+
96+
// DefaultExecutor uses os/exec to run commands.
97+
type DefaultExecutor struct{}
98+
99+
// Execute runs a command and returns its stdout output.
100+
func (e *DefaultExecutor) Execute(ctx context.Context, name string, args ...string) ([]byte, error) {
101+
cmd := exec.CommandContext(ctx, name, args...)
102+
103+
var stdout bytes.Buffer
104+
var stderr bytes.Buffer
105+
cmd.Stdout = &stdout
106+
cmd.Stderr = &stderr
107+
108+
if err := cmd.Run(); err != nil {
109+
return nil, &pipError{
110+
err: err,
111+
stderr: stderr.String(),
112+
}
113+
}
114+
115+
return stdout.Bytes(), nil
116+
}
117+
118+
// pipError wraps an error with stderr output from pip.
119+
type pipError struct {
120+
err error
121+
stderr string
122+
}
123+
124+
func (e *pipError) Error() string {
125+
return fmt.Sprintf("%v (stderr: %s)", e.err, e.stderr)
126+
}
127+
128+
func (e *pipError) Unwrap() error {
129+
return e.err
130+
}
131+
132+
// classifyPipError analyzes pip error output and returns appropriate error catalog error.
133+
//
134+
//nolint:gocyclo // Error classification requires checking multiple patterns sequentially
135+
func classifyPipError(err error) error {
136+
var pipErr *pipError
137+
if !errors.As(err, &pipErr) {
138+
// Not a pip error, return as-is
139+
return fmt.Errorf("pip install failed: %w", err)
140+
}
141+
142+
stderr := pipErr.stderr
143+
144+
// Check for syntax errors in requirements.txt
145+
if strings.Contains(stderr, "Invalid requirement") ||
146+
strings.Contains(stderr, "Could not parse") ||
147+
strings.Contains(stderr, "invalid requirement") ||
148+
strings.Contains(stderr, "InvalidVersion") ||
149+
strings.Contains(stderr, "Invalid version") {
150+
return ecosystems.NewSyntaxIssuesError(
151+
fmt.Sprintf("Invalid syntax in requirements file: %s", stderr),
152+
snyk_errors.WithCause(pipErr.err),
153+
)
154+
}
155+
156+
// Check for package not found errors
157+
if strings.Contains(stderr, "Could not find a version") ||
158+
strings.Contains(stderr, "No matching distribution") ||
159+
strings.Contains(stderr, "Could not find a version that satisfies") {
160+
return ecosystems.NewPythonPackageNotFoundError(
161+
fmt.Sprintf("Package not found: %s", stderr),
162+
snyk_errors.WithCause(pipErr.err),
163+
)
164+
}
165+
166+
// Check for Python version mismatch
167+
if strings.Contains(stderr, "requires Python") ||
168+
strings.Contains(stderr, "Requires-Python") {
169+
return ecosystems.NewPipUnsupportedPythonVersionError(
170+
fmt.Sprintf("Python version mismatch: %s", stderr),
171+
snyk_errors.WithCause(pipErr.err),
172+
)
173+
}
174+
175+
// Check for conflicting requirements
176+
if strings.Contains(stderr, "Conflict") ||
177+
strings.Contains(stderr, "conflicting") ||
178+
strings.Contains(stderr, "incompatible") {
179+
return ecosystems.NewPythonVersionConfictError(
180+
fmt.Sprintf("Conflicting package requirements: %s", stderr),
181+
snyk_errors.WithCause(pipErr.err),
182+
)
183+
}
184+
185+
// Check for context cancellation or timeout
186+
if errors.Is(pipErr.err, context.Canceled) {
187+
// User-initiated cancellation (e.g., Ctrl+C) - not a catalog error
188+
return fmt.Errorf("pip install canceled: %w", pipErr.err)
189+
}
190+
if errors.Is(pipErr.err, context.DeadlineExceeded) {
191+
// Timeout - use catalog timeout error
192+
return snyk.NewTimeoutError(
193+
fmt.Sprintf("Pip install timed out: %s", stderr),
194+
snyk_errors.WithCause(pipErr.err),
195+
)
196+
}
197+
198+
// Generic installation failure
199+
return ecosystems.NewInstallationFailureError(
200+
fmt.Sprintf("Pip install failed: %s", stderr),
201+
snyk_errors.WithCause(pipErr.err),
202+
)
203+
}

0 commit comments

Comments
 (0)