From fc9fddb3245ef5decf99bc89b859eae701ddf665 Mon Sep 17 00:00:00 2001 From: Sahilb315 Date: Sat, 17 May 2025 02:54:45 +0530 Subject: [PATCH] feat: Add PyPi resolver --- packagemanager/pypi.go | 72 +------------- packagemanager/pypi_resolver.go | 166 ++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 71 deletions(-) create mode 100644 packagemanager/pypi_resolver.go diff --git a/packagemanager/pypi.go b/packagemanager/pypi.go index 6dc2248..69c565b 100644 --- a/packagemanager/pypi.go +++ b/packagemanager/pypi.go @@ -1,18 +1,12 @@ package packagemanager import ( - "encoding/json" "fmt" - "io" - "net/http" "slices" - "sort" "strconv" "strings" - "time" packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" - "github.com/Masterminds/semver" ) type PipPackageManagerConfig struct { @@ -152,71 +146,7 @@ func pipParsePackageInfo(input string) (packageName, version string, err error) return packageName, version, nil } -var httpClient = &http.Client{Timeout: 10 * time.Second} - -func pipGetLatestMatchingVersion(packageName, versionConstraint string) (string, error) { - type PyPIResponse struct { - Releases map[string]any `json:"releases"` - } - - if strings.HasPrefix(versionConstraint, "~=") { - versionConstraint = pipConvertCompatibleRelease(versionConstraint) - } - - url := fmt.Sprintf("https://pypi.org/pypi/%s/json", packageName) - resp, err := httpClient.Get(url) - if err != nil { - return "", fmt.Errorf("failed to fetch package info: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return "", fmt.Errorf("package not found or HTTP error: %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read response body: %w", err) - } - - var pypiResp PyPIResponse - if err := json.Unmarshal(body, &pypiResp); err != nil { - return "", fmt.Errorf("failed to parse JSON: %w", err) - } - - // Parse version constraint - constraint, err := semver.NewConstraint(versionConstraint) - if err != nil { - return "", fmt.Errorf("invalid version constraint: %w", err) - } - - // Collect all valid semver versions - var versions []*semver.Version - for v := range pypiResp.Releases { - ver, err := semver.NewVersion(v) - if err == nil { // ignore invalid semver versions - versions = append(versions, ver) - } - } - - if len(versions) == 0 { - return "", fmt.Errorf("no valid versions found") - } - - // Sort versions in ascending order - sort.Sort(semver.Collection(versions)) - - // Iterate from highest to lowest to find best match - for i := len(versions) - 1; i >= 0; i-- { - if constraint.Check(versions[i]) { - return versions[i].Original(), nil - } - } - - return "", fmt.Errorf("no version matches constraint %q", versionConstraint) -} - -// Convert "~=3.1.0" → ">=3.1.0,<3.2.0" +// convert "~=3.1.0" → ">=3.1.0,<3.2.0" func pipConvertCompatibleRelease(version string) string { version = strings.TrimPrefix(version, "~=") parts := strings.Split(version, ".") diff --git a/packagemanager/pypi_resolver.go b/packagemanager/pypi_resolver.go new file mode 100644 index 0000000..7a4e5a5 --- /dev/null +++ b/packagemanager/pypi_resolver.go @@ -0,0 +1,166 @@ +package packagemanager + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" + "github.com/Masterminds/semver" + "github.com/safedep/dry/log" + "github.com/safedep/dry/packageregistry" +) + +type PyPiDependencyResolverConfig struct { + IncludeDevDependencies bool + IncludeTransitiveDependencies bool + TransitiveDepth int + + // FailFast will stop resolving dependencies after the first error + FailFast bool + + // MaxConcurrency limits the number of concurrent goroutines used for dependency resolution + MaxConcurrency int +} + +func NewDefaultPypiDependencyResolverConfig() PyPiDependencyResolverConfig { + return PyPiDependencyResolverConfig{ + IncludeDevDependencies: false, + IncludeTransitiveDependencies: true, + TransitiveDepth: 5, + FailFast: false, + MaxConcurrency: 10, + } +} + +type pypiDependencyResolver struct { + registry packageregistry.Client + config PyPiDependencyResolverConfig +} + +var _ PackageResolver = &pypiDependencyResolver{} + +func NewPypiDependencyResolver(config PyPiDependencyResolverConfig) (*pypiDependencyResolver, error) { + client, err := packageregistry.NewPypiAdapter() + if err != nil { + return nil, fmt.Errorf("failed to create pypi adapter: %w", err) + } + + return &pypiDependencyResolver{ + config: config, + registry: client, + }, nil +} + +func (p *pypiDependencyResolver) ResolveDependencies(ctx context.Context, pkg *packagev1.PackageVersion) ([]*packagev1.PackageVersion, error) { + resolver := newDependencyResolver(p.registry, dependencyResolverConfig{ + IncludeDevDependencies: p.config.IncludeDevDependencies, + IncludeTransitiveDependencies: p.config.IncludeTransitiveDependencies, + TransitiveDepth: p.config.TransitiveDepth, + FailFast: p.config.FailFast, + MaxConcurrency: p.config.MaxConcurrency, + }) + + return resolver.resolveDependencies(ctx, pkg) +} + +func (p *pypiDependencyResolver) ResolveLatestVersion(ctx context.Context, pkg *packagev1.Package) (*packagev1.PackageVersion, error) { + pd, err := p.registry.PackageDiscovery() + if err != nil { + return nil, fmt.Errorf("failed to get package discovery: %w", err) + } + + pkgInfo, err := pd.GetPackage(pkg.Name) + if err != nil { + return nil, fmt.Errorf("failed to get package: %w", err) + } + log.Debugf("Resolved pypi/%s to latest version %s", pkg.Name, pkgInfo.LatestVersion) + + return &packagev1.PackageVersion{ + Package: pkg, + Version: pkgInfo.LatestVersion, + }, nil +} + +// PyPIPackage represents the package information from PyPI +type PyPIPackage struct { + Releases map[string]any `json:"releases"` +} + +var httpClient = &http.Client{Timeout: 10 * time.Second} + +func pipGetLatestMatchingVersion(packageName, versionConstraint string) (string, error) { + // Handle compatible release operator + if strings.HasPrefix(versionConstraint, "~=") { + versionConstraint = pipConvertCompatibleRelease(versionConstraint) + } + + // Get package info from PyPI + pkg, err := pipFetchPackageVersionsInfo(packageName) + if err != nil { + return "", err + } + + // Parse version constraint + constraint, err := semver.NewConstraint(versionConstraint) + if err != nil { + return "", fmt.Errorf("invalid version constraint: %w", err) + } + + // Get valid versions and find best match + bestMatch, err := findBestMatchingVersion(pkg.Releases, constraint) + if err != nil { + return "", fmt.Errorf("no version matches constraint %q: %w", versionConstraint, err) + } + + return bestMatch.Original(), nil +} + +// pipFetchPackageVersionsInfo retrieves package information from PyPI +func pipFetchPackageVersionsInfo(packageName string) (*PyPIPackage, error) { + url := fmt.Sprintf("https://pypi.org/pypi/%s/json", packageName) + resp, err := httpClient.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch package info: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("package not found or HTTP error: %d", resp.StatusCode) + } + + var pypiPkg PyPIPackage + if err := json.NewDecoder(resp.Body).Decode(&pypiPkg); err != nil { + return nil, fmt.Errorf("failed to parse JSON: %w", err) + } + + return &pypiPkg, nil +} + +func findBestMatchingVersion(releases map[string]any, constraint *semver.Constraints) (*semver.Version, error) { + if len(releases) == 0 { + return nil, fmt.Errorf("no versions available") + } + + var bestMatch *semver.Version + // We'll iterate once through all versions + for v := range releases { + ver, err := semver.NewVersion(v) + if err != nil { + continue // Skip invalid versions + } + + // Update bestMatch if this version is higher and matches constraint + if constraint.Check(ver) && (bestMatch == nil || ver.GreaterThan(bestMatch)) { + bestMatch = ver + } + } + + if bestMatch == nil { + return nil, fmt.Errorf("no version matches constraint") + } + return bestMatch, nil +}