2025-06-09 17:37:08 +05:30
|
|
|
package packagemanager
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
|
|
|
|
"regexp"
|
|
|
|
|
"slices"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
PackageInstallTargets []*PackageInstallTarget
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewDefaultPypiDependencyResolverConfig() PyPiDependencyResolverConfig {
|
|
|
|
|
return PyPiDependencyResolverConfig{
|
|
|
|
|
IncludeDevDependencies: false,
|
|
|
|
|
IncludeTransitiveDependencies: true,
|
|
|
|
|
TransitiveDepth: 5,
|
|
|
|
|
FailFast: false,
|
|
|
|
|
MaxConcurrency: 10,
|
|
|
|
|
PackageInstallTargets: []*PackageInstallTarget{},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
pypiVersionSpecResolverFn := func(packageName, version string) string {
|
2025-08-06 21:10:51 +05:30
|
|
|
ver, err := pypiGetMatchingVersion(packageName, version)
|
2025-06-09 17:37:08 +05:30
|
|
|
if err != nil {
|
|
|
|
|
log.Debugf("error getting matching version for %s@%s", packageName, version)
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return ver
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pypiDependencyResolverFn := func(packageName, version string) (*packageregistry.PackageDependencyList, error) {
|
|
|
|
|
resolvedDependencies, err := getPypiPackageDependencies(packageName, version, p.config.PackageInstallTargets)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
dependencies := make([]packageregistry.PackageDependencyInfo, 0)
|
|
|
|
|
for _, dep := range resolvedDependencies {
|
|
|
|
|
dependencies = append(dependencies, packageregistry.PackageDependencyInfo{
|
|
|
|
|
Name: dep.PackageNameExtra,
|
|
|
|
|
VersionSpec: dep.VersionSpec,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &packageregistry.PackageDependencyList{
|
|
|
|
|
Dependencies: dependencies,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Python treats package names with '-' and '_' as equivalent (e.g., 'my-package' and 'my_package' refer to the same package)
|
|
|
|
|
packageKeyFn := func(pkg *packagev1.PackageVersion) string {
|
|
|
|
|
normalizedName := normalizePackageName(pkg.Package.Name)
|
|
|
|
|
return fmt.Sprintf("%s@%s", normalizedName, pkg.Version)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}, pypiVersionSpecResolverFn, pypiDependencyResolverFn, packageKeyFn)
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type pypiPackage struct {
|
|
|
|
|
Info pypiPackageInfo `json:"info"`
|
|
|
|
|
Releases map[string]any `json:"releases"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type PyPIDependencySpec struct {
|
|
|
|
|
// PackageNameExtra is the package name including any direct extras in brackets
|
|
|
|
|
// Example: "uvicorn[standard]"
|
|
|
|
|
PackageNameExtra string
|
|
|
|
|
|
|
|
|
|
// VersionSpec is the version constraint for the package
|
|
|
|
|
// Example: ">=0.12.0", "==1.0.0", ">=2.0,<3.0"
|
|
|
|
|
VersionSpec string
|
|
|
|
|
|
|
|
|
|
// Extra is the conditional extra marker that defines when this dependency applies
|
|
|
|
|
// Example: "all" from "; extra == \"all\""
|
|
|
|
|
Extra string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type pypiPackageInfo struct {
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Description string `json:"summary"`
|
|
|
|
|
LatestVersion string `json:"version"`
|
|
|
|
|
PackageURL string `json:"package_url"`
|
|
|
|
|
Author string `json:"author"`
|
|
|
|
|
AuthorEmail string `json:"author_email"`
|
|
|
|
|
Maintainer string `json:"maintainer"`
|
|
|
|
|
MaintainerEmail string `json:"maintainer_email"`
|
|
|
|
|
RequiresDist []string `json:"requires_dist"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func getPypiPackageDependencies(packageName, version string, packageTargets []*PackageInstallTarget) ([]PyPIDependencySpec, error) {
|
|
|
|
|
url := fmt.Sprintf("https://pypi.org/pypi/%s/%s/json", packageName, version)
|
|
|
|
|
|
|
|
|
|
res, err := http.Get(url)
|
|
|
|
|
if err != nil {
|
2025-08-21 18:26:05 +05:30
|
|
|
return nil, ErrFailedToFetchPackage.Wrap(err)
|
2025-06-09 17:37:08 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if res.StatusCode == 404 {
|
2025-08-21 18:26:05 +05:30
|
|
|
return nil, ErrPackageNotFound.Wrap(err)
|
2025-06-09 17:37:08 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if res.StatusCode != 200 {
|
2025-08-21 18:26:05 +05:30
|
|
|
return nil, ErrFailedToFetchPackage.Wrap(err)
|
2025-06-09 17:37:08 +05:30
|
|
|
}
|
2026-05-19 14:40:54 +05:30
|
|
|
defer func() {
|
|
|
|
|
if err := res.Body.Close(); err != nil {
|
|
|
|
|
log.Warnf("failed to close PyPI response body: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}()
|
2025-06-09 17:37:08 +05:30
|
|
|
|
|
|
|
|
var pypipkg pypiPackage
|
|
|
|
|
err = json.NewDecoder(res.Body).Decode(&pypipkg)
|
|
|
|
|
if err != nil {
|
2025-08-21 18:26:05 +05:30
|
|
|
return nil, ErrFailedToParsePackage.Wrap(err)
|
2025-06-09 17:37:08 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find if this package has any specified extras in the install targets
|
|
|
|
|
var requestedExtras []string
|
|
|
|
|
for _, target := range packageTargets {
|
|
|
|
|
if target.PackageVersion.Package.Name == packageName {
|
|
|
|
|
requestedExtras = target.Extras
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pkgDeps := make([]PyPIDependencySpec, 0, len(pypipkg.Info.RequiresDist))
|
|
|
|
|
|
|
|
|
|
for _, dep := range pypipkg.Info.RequiresDist {
|
|
|
|
|
name, version, extra := pypiParseDependency(dep)
|
|
|
|
|
|
|
|
|
|
// Include dependencies if they either:
|
|
|
|
|
// 1. Have no extras (base dependencies)
|
|
|
|
|
// 2. Have an extra that matches one of our requested extras
|
|
|
|
|
if extra == "" || (len(requestedExtras) > 0 && slices.Contains(requestedExtras, extra)) {
|
|
|
|
|
pkgDeps = append(pkgDeps, PyPIDependencySpec{
|
|
|
|
|
PackageNameExtra: name,
|
|
|
|
|
VersionSpec: version,
|
|
|
|
|
Extra: extra,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return pkgDeps, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// pypiParseDependency parses a PyPI dependency specification, handling both package extras
|
|
|
|
|
// and conditional dependencies. Keeps extras as part of the package name.
|
|
|
|
|
// Example: "uvicorn[standard]>=0.12.0; extra == \"all\"" returns ("uvicorn[standard]", ">=0.12.0", "all")
|
|
|
|
|
func pypiParseDependency(input string) (string, string, string) {
|
|
|
|
|
// Split line by ';' to separate version and markers
|
|
|
|
|
parts := strings.SplitN(input, ";", 2)
|
|
|
|
|
mainPart := strings.TrimSpace(parts[0])
|
|
|
|
|
|
|
|
|
|
// Regex to match the first occurrence of version operators
|
|
|
|
|
// Using lookahead to ensure we match standalone operators
|
|
|
|
|
operatorRegex := regexp.MustCompile(`(==|>=|<=|!=|>|<|~=)(?:\d|$)`)
|
|
|
|
|
match := operatorRegex.FindStringIndex(mainPart)
|
|
|
|
|
|
|
|
|
|
var name, version string
|
|
|
|
|
if match != nil {
|
|
|
|
|
// Everything before the operator is the name
|
|
|
|
|
name = strings.TrimSpace(mainPart[:match[0]])
|
|
|
|
|
// Remove trailing parentheses from name if present
|
|
|
|
|
name = strings.TrimRight(name, " (")
|
|
|
|
|
|
|
|
|
|
// Everything from the operator onwards is the version spec
|
|
|
|
|
version = strings.TrimSpace(mainPart[match[0]:])
|
|
|
|
|
// Remove parentheses from version spec if present
|
|
|
|
|
version = strings.Trim(version, "()")
|
|
|
|
|
} else {
|
|
|
|
|
// No version operator found
|
|
|
|
|
name = mainPart
|
|
|
|
|
version = ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Extract extra marker if present
|
|
|
|
|
var extra string
|
|
|
|
|
if len(parts) == 2 {
|
|
|
|
|
extraRe := regexp.MustCompile(`extra\s*==\s*["']([^"']+)["']`)
|
|
|
|
|
if match := extraRe.FindStringSubmatch(parts[1]); len(match) == 2 {
|
|
|
|
|
extra = match[1]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return name, version, extra
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-06 21:10:51 +05:30
|
|
|
func pypiGetMatchingVersion(packageName, versionConstraint string) (string, error) {
|
2025-06-09 17:37:08 +05:30
|
|
|
// Already a exact version
|
2025-08-29 00:21:09 +05:30
|
|
|
if after, ok := strings.CutPrefix(versionConstraint, "=="); ok {
|
|
|
|
|
return after, nil
|
2025-06-09 17:37:08 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle compatible release operator
|
|
|
|
|
if strings.HasPrefix(versionConstraint, "~=") {
|
2025-08-06 21:10:51 +05:30
|
|
|
versionConstraint = pypiConvertCompatibleRelease(versionConstraint)
|
2025-06-09 17:37:08 +05:30
|
|
|
}
|
2025-08-29 00:21:09 +05:30
|
|
|
|
2025-06-09 17:37:08 +05:30
|
|
|
// Handle empty version constraint
|
|
|
|
|
if versionConstraint == "" {
|
|
|
|
|
// Get latest version
|
|
|
|
|
registry, err := packageregistry.NewPypiAdapter()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", fmt.Errorf("failed to create pypi adapter: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pd, err := registry.PackageDiscovery()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", fmt.Errorf("failed to get package discovery: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pkg, err := pd.GetPackage(packageName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return pkg.LatestVersion, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
registry, err := packageregistry.NewPypiAdapter()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", fmt.Errorf("failed to create pypi adapter: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pd, err := registry.PackageDiscovery()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", fmt.Errorf("failed to get package discovery: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get package info from PyPI
|
|
|
|
|
pkg, err := pd.GetPackage(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.Versions, constraint)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", fmt.Errorf("no version matches constraint %q: %w", versionConstraint, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return bestMatch.Original(), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func findBestMatchingVersion(releases []packageregistry.PackageVersionInfo, 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.Version)
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func normalizePackageName(name string) string {
|
|
|
|
|
// Convert to lowercase
|
|
|
|
|
name = strings.ToLower(name)
|
|
|
|
|
|
|
|
|
|
// Replace any sequence of [-_.] with a single hyphen
|
|
|
|
|
re := regexp.MustCompile(`[-_.]+`)
|
|
|
|
|
name = re.ReplaceAllString(name, "-")
|
|
|
|
|
|
|
|
|
|
return name
|
|
|
|
|
}
|