mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat: Add PyPi resolver
This commit is contained in:
+1
-71
@@ -1,18 +1,12 @@
|
|||||||
package packagemanager
|
package packagemanager
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
||||||
"github.com/Masterminds/semver"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type PipPackageManagerConfig struct {
|
type PipPackageManagerConfig struct {
|
||||||
@@ -152,71 +146,7 @@ func pipParsePackageInfo(input string) (packageName, version string, err error)
|
|||||||
return packageName, version, nil
|
return packageName, version, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var httpClient = &http.Client{Timeout: 10 * time.Second}
|
// convert "~=3.1.0" → ">=3.1.0,<3.2.0"
|
||||||
|
|
||||||
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"
|
|
||||||
func pipConvertCompatibleRelease(version string) string {
|
func pipConvertCompatibleRelease(version string) string {
|
||||||
version = strings.TrimPrefix(version, "~=")
|
version = strings.TrimPrefix(version, "~=")
|
||||||
parts := strings.Split(version, ".")
|
parts := strings.Split(version, ".")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user