mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat/init-pip-cmd
This commit is contained in:
@@ -2,7 +2,6 @@ package npm
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
_ "embed"
|
|
||||||
|
|
||||||
"github.com/safedep/dry/log"
|
"github.com/safedep/dry/log"
|
||||||
"github.com/safedep/pmg/internal/flows"
|
"github.com/safedep/pmg/internal/flows"
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package pypi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/safedep/dry/log"
|
||||||
|
"github.com/safedep/pmg/config"
|
||||||
|
"github.com/safedep/pmg/internal/ui"
|
||||||
|
"github.com/safedep/pmg/packagemanager"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewPipCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "pip [action] [package]",
|
||||||
|
Short: "Guard pip package manager",
|
||||||
|
DisableFlagParsing: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
config, err := config.FromContext(cmd.Context())
|
||||||
|
if err != nil {
|
||||||
|
ui.Fatalf("Failed to get config: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = executePipFlow(cmd.Context(), config, args)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Failed to execute pip flow: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func executePipFlow(context context.Context, config config.Config, args []string) error {
|
||||||
|
packageManager, err := packagemanager.NewPipPackageManager(packagemanager.DefaultPipPackageManagerConfig())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create pip package manager: %w", err)
|
||||||
|
}
|
||||||
|
cmd, _ := packageManager.ParseCommand(args)
|
||||||
|
fmt.Println("Cmd: ", cmd.InstallTargets[0])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ tool github.com/golangci/golangci-lint/cmd/golangci-lint
|
|||||||
require (
|
require (
|
||||||
buf.build/gen/go/safedep/api/grpc/go v1.5.1-20250418165058-162f6b0cc319.2
|
buf.build/gen/go/safedep/api/grpc/go v1.5.1-20250418165058-162f6b0cc319.2
|
||||||
buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.6-20250418165058-162f6b0cc319.1
|
buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.6-20250418165058-162f6b0cc319.1
|
||||||
|
github.com/Masterminds/semver v1.5.0
|
||||||
github.com/fatih/color v1.18.0
|
github.com/fatih/color v1.18.0
|
||||||
github.com/jedib0t/go-pretty/v6 v6.6.7
|
github.com/jedib0t/go-pretty/v6 v6.6.7
|
||||||
github.com/safedep/dry v0.0.0-20250514080944-bb77f30c7175
|
github.com/safedep/dry v0.0.0-20250514080944-bb77f30c7175
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 h1:sHglBQTwgx+rW
|
|||||||
github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs=
|
github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs=
|
||||||
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 h1:Sz1JIXEcSfhz7fUi7xHnhpIE0thVASYjvosApmHuD2k=
|
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 h1:Sz1JIXEcSfhz7fUi7xHnhpIE0thVASYjvosApmHuD2k=
|
||||||
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1/go.mod h1:n/LSCXNuIYqVfBlVXyHfMQkZDdp1/mmxfSjADd3z1Zg=
|
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1/go.mod h1:n/LSCXNuIYqVfBlVXyHfMQkZDdp1/mmxfSjADd3z1Zg=
|
||||||
|
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
|
||||||
|
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
|
||||||
github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4=
|
github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4=
|
||||||
github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
|
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/safedep/dry/log"
|
"github.com/safedep/dry/log"
|
||||||
"github.com/safedep/pmg/cmd/npm"
|
"github.com/safedep/pmg/cmd/npm"
|
||||||
|
"github.com/safedep/pmg/cmd/pypi"
|
||||||
"github.com/safedep/pmg/cmd/version"
|
"github.com/safedep/pmg/cmd/version"
|
||||||
"github.com/safedep/pmg/config"
|
"github.com/safedep/pmg/config"
|
||||||
"github.com/safedep/pmg/internal/ui"
|
"github.com/safedep/pmg/internal/ui"
|
||||||
@@ -79,6 +80,7 @@ func main() {
|
|||||||
|
|
||||||
cmd.AddCommand(npm.NewNpmCommand())
|
cmd.AddCommand(npm.NewNpmCommand())
|
||||||
cmd.AddCommand(npm.NewPnpmCommand())
|
cmd.AddCommand(npm.NewPnpmCommand())
|
||||||
|
cmd.AddCommand(pypi.NewPipCommand())
|
||||||
cmd.AddCommand(version.NewVersionCommand())
|
cmd.AddCommand(version.NewVersionCommand())
|
||||||
|
|
||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ func NewNpmPackageManager(config NpmPackageManagerConfig) (*npmPackageManager, e
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _ PackageManager = &npmPackageManager{}
|
||||||
|
|
||||||
func (npm *npmPackageManager) Name() string {
|
func (npm *npmPackageManager) Name() string {
|
||||||
return "npm"
|
return "npm"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
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 {
|
||||||
|
InstallCommands []string
|
||||||
|
CommandName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultPipPackageManagerConfig() PipPackageManagerConfig {
|
||||||
|
return PipPackageManagerConfig{
|
||||||
|
InstallCommands: []string{"install"},
|
||||||
|
CommandName: "pip",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type pipPackageManager struct {
|
||||||
|
Config PipPackageManagerConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPipPackageManager(config PipPackageManagerConfig) (*pipPackageManager, error) {
|
||||||
|
return &pipPackageManager{
|
||||||
|
Config: config,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ PackageManager = &pipPackageManager{}
|
||||||
|
|
||||||
|
func (pip *pipPackageManager) Name() string {
|
||||||
|
return "pip"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pip *pipPackageManager) ParseCommand(args []string) (*ParsedCommand, error) {
|
||||||
|
if len(args) > 0 && args[0] == "pip" {
|
||||||
|
args = args[1:]
|
||||||
|
}
|
||||||
|
command := Command{Exe: pip.Config.CommandName, Args: args}
|
||||||
|
|
||||||
|
if len(args) < 2 {
|
||||||
|
return &ParsedCommand{
|
||||||
|
Command: command,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var packages []string
|
||||||
|
for idx, arg := range args {
|
||||||
|
if slices.Contains(pip.Config.InstallCommands, arg) {
|
||||||
|
for i := idx + 1; i < len(args); i++ {
|
||||||
|
if strings.HasPrefix(args[i], "-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
packages = append(packages, args[i])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var installTargets []*PackageInstallTarget
|
||||||
|
|
||||||
|
for _, pkg := range packages {
|
||||||
|
packageName, version, err := pipParsePackageInfo(pkg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse package info: %w", err)
|
||||||
|
}
|
||||||
|
// If exact version provided just trim it. If not get a version that satisfies a given version specifier
|
||||||
|
|
||||||
|
if version != "" {
|
||||||
|
if strings.HasPrefix(version, "==") {
|
||||||
|
// Exact version, just trim
|
||||||
|
version = strings.TrimPrefix(version, "==")
|
||||||
|
} else {
|
||||||
|
// Version range, resolve from PyPI
|
||||||
|
version, err = pipGetLatestMatchingVersion(packageName, version)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error resolving version for %s: %s", packageName, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Package Name: %s Version: %s\n", packageName, version)
|
||||||
|
|
||||||
|
installTargets = append(installTargets, &PackageInstallTarget{
|
||||||
|
PackageVersion: &packagev1.PackageVersion{
|
||||||
|
Package: &packagev1.Package{
|
||||||
|
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_PYPI,
|
||||||
|
Name: packageName,
|
||||||
|
},
|
||||||
|
Version: version,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ParsedCommand{
|
||||||
|
Command: command,
|
||||||
|
InstallTargets: installTargets,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pipParsePackageInfo parses python package strings like:
|
||||||
|
// "fastapi", "fastapi==0.115.7", "requests>=2.0,<3.0", "pydantic!=1.8,!=1.8.1"
|
||||||
|
// Returns packageName and version (empty if none specified).
|
||||||
|
func pipParsePackageInfo(input string) (packageName, version string, err error) {
|
||||||
|
if input == "" {
|
||||||
|
return "", "", fmt.Errorf("package info cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
input = strings.TrimSpace(input)
|
||||||
|
|
||||||
|
// Python package version specifiers are typically separated by one of:
|
||||||
|
// '==', '>=', '<=', '!=', '>', '<', '~=', or direct comma separated list
|
||||||
|
// We'll find the first occurrence of these operators for splitting.
|
||||||
|
|
||||||
|
operators := []string{"==", ">=", "<=", "!=", ">", "<", "~="}
|
||||||
|
index := -1
|
||||||
|
|
||||||
|
// Find the earliest operator occurrence
|
||||||
|
for _, op := range operators {
|
||||||
|
i := strings.Index(input, op)
|
||||||
|
if i != -1 && (index == -1 || i < index) {
|
||||||
|
index = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if index == -1 {
|
||||||
|
// No operator found, whole input is package name, no version
|
||||||
|
return input, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
packageName = strings.TrimSpace(input[:index])
|
||||||
|
version = strings.TrimSpace(input[index:])
|
||||||
|
|
||||||
|
// Some version specs can have multiple constraints separated by commas
|
||||||
|
// Example: "requests>=2.0,<3.0"
|
||||||
|
// So keep version as is
|
||||||
|
|
||||||
|
if packageName == "" {
|
||||||
|
return "", "", fmt.Errorf("invalid package name in input '%s'", input)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
func pipConvertCompatibleRelease(version string) string {
|
||||||
|
version = strings.TrimPrefix(version, "~=")
|
||||||
|
parts := strings.Split(version, ".")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return "" // invalid
|
||||||
|
}
|
||||||
|
major := parts[0]
|
||||||
|
minor := parts[1]
|
||||||
|
nextMinor, _ := strconv.Atoi(minor)
|
||||||
|
nextMinor += 1
|
||||||
|
return fmt.Sprintf(">=%s,<%s.%d.0", version, major, nextMinor)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user