feat : adds pipx support to PMG (#292)

* adding the pipx support to project

Signed-off-by: DivyanshuVortex <divyanshuchandra9027@gmail.com>

* create excutors for pipx

Signed-off-by: DivyanshuVortex <divyanshuchandra9027@gmail.com>

* add pipx yml

Signed-off-by: DivyanshuVortex <divyanshuchandra9027@gmail.com>

* chores

Signed-off-by: DivyanshuVortex <divyanshuchandra9027@gmail.com>

* pipx to use standard executor pattern

Signed-off-by: DivyanshuVortex <divyanshuchandra9027@gmail.com>

* Address PR feedback for pipx support

Signed-off-by: DivyanshuVortex <divyanshuchandra9027@gmail.com>

* Update pipx flags comments

Signed-off-by: DivyanshuVortex <divyanshuchandra9027@gmail.com>

---------

Signed-off-by: DivyanshuVortex <divyanshuchandra9027@gmail.com>
Co-authored-by: Abhisek Datta <abhisek.datta@gmail.com>
Co-authored-by: Sahil Bansal <bansalsahil315@gmail.com>
This commit is contained in:
Divyanshu
2026-06-10 16:26:33 +05:30
committed by GitHub
co-authored by Abhisek Datta Sahil Bansal
parent ab1a5386d3
commit 7620097613
11 changed files with 584 additions and 2 deletions
+1
View File
@@ -149,6 +149,7 @@ PMG supports the tools you already use:
| | `npx` | `npx <pkg>` | | | `npx` | `npx <pkg>` |
| | `pnpx` | `pnpx <pkg>` | | | `pnpx` | `pnpx <pkg>` |
| **Python** | `pip` | `pip install <pkg>` | | **Python** | `pip` | `pip install <pkg>` |
| | `pipx` | `pipx run <pkg>` |
| | `poetry` | `poetry add <pkg>` | | | `poetry` | `poetry add <pkg>` |
| | `uv` | `uv add <pkg>` | | | `uv` | `uv add <pkg>` |
+61
View File
@@ -0,0 +1,61 @@
package executors
import (
"context"
"fmt"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/analytics"
"github.com/safedep/pmg/internal/flows"
"github.com/safedep/pmg/internal/ui"
"github.com/safedep/pmg/packagemanager"
"github.com/spf13/cobra"
)
func NewPipxCommand() *cobra.Command {
return &cobra.Command{
Use: "pipx [action] [package]",
Short: "Guard pipx package executor",
DisableFlagParsing: true,
RunE: func(cmd *cobra.Command, args []string) error {
err := executePipxFlow(cmd.Context(), args)
if err != nil {
ui.ExitFromCommandError(err)
}
return nil
},
}
}
func executePipxFlow(ctx context.Context, args []string) error {
analytics.TrackCommandPipx()
packageExecutor, err := packagemanager.NewPypiPackageExecutor(packagemanager.DefaultPipxPackageExecutorConfig())
if err != nil {
return fmt.Errorf("failed to create pipx package executor proxy: %w", err)
}
config := config.Get()
parsedCommand, err := packageExecutor.ParseCommand(args)
if err != nil {
return fmt.Errorf("failed to parse command: %w", err)
}
packageResolverConfig := packagemanager.NewDefaultPypiDependencyResolverConfig()
packageResolverConfig.IncludeTransitiveDependencies = config.Config.Transitive
packageResolverConfig.TransitiveDepth = config.Config.TransitiveDepth
packageResolverConfig.IncludeDevDependencies = config.Config.IncludeDevDependencies
packageResolverConfig.PackageInstallTargets = parsedCommand.InstallTargets
packageResolver, err := packagemanager.NewPypiDependencyResolver(packageResolverConfig)
if err != nil {
return fmt.Errorf("failed to create dependency resolver: %w", err)
}
if !config.IsProxyModeEnabled() {
return flows.Common(packageExecutor, packageResolver).Run(ctx, args, parsedCommand)
}
return flows.ProxyFlow(packageExecutor, packageResolver).Run(ctx, args, parsedCommand)
}
+4
View File
@@ -145,6 +145,10 @@ sandbox:
enabled: true enabled: true
profile: pypi-restrictive profile: pypi-restrictive
pipx:
enabled: true
profile: pypi-restrictive
poetry: poetry:
enabled: true enabled: true
profile: pypi-restrictive profile: pypi-restrictive
+1 -1
View File
@@ -97,7 +97,7 @@ func DefaultConfig() AliasConfig {
return AliasConfig{ return AliasConfig{
RcFileName: ".pmg.rc", RcFileName: ".pmg.rc",
PackageManagers: []string{"npm", "pip", "pip3", "pnpm", "bun", "uv", "yarn", "poetry", "npx", "pnpx"}, PackageManagers: []string{"npm", "pip", "pip3", "pipx", "pnpm", "bun", "uv", "yarn", "poetry", "npx", "pnpx"},
Shells: shells, Shells: shells,
} }
} }
+5
View File
@@ -10,6 +10,7 @@ const (
eventCommandPip3 = "pmg_command_pip3" eventCommandPip3 = "pmg_command_pip3"
eventCommandUv = "pmg_command_uv" eventCommandUv = "pmg_command_uv"
eventCommandPoetry = "pmg_command_poetry" eventCommandPoetry = "pmg_command_poetry"
eventCommandPipx = "pmg_command_pipx"
eventCommandNpx = "pmg_command_npx" eventCommandNpx = "pmg_command_npx"
eventCommandPnpx = "pmg_command_pnpx" eventCommandPnpx = "pmg_command_pnpx"
@@ -63,6 +64,10 @@ func TrackCommandPoetry() {
TrackEvent(eventCommandPoetry) TrackEvent(eventCommandPoetry)
} }
func TrackCommandPipx() {
TrackEvent(eventCommandPipx)
}
func TrackCommandGenerateEnvDocker() { func TrackCommandGenerateEnvDocker() {
TrackEvent(eventPmgGenerateEnvDocker) TrackEvent(eventPmgGenerateEnvDocker)
} }
+1 -1
View File
@@ -193,7 +193,7 @@ func mapPackageManager(name string) controltowerv1.PmgPackageManager {
return controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_YARN return controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_YARN
case "bun": case "bun":
return controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_BUN return controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_BUN
case "pip", "pip3": case "pip", "pip3", "pipx":
return controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_PIP return controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_PIP
case "poetry": case "poetry":
return controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_POETRY return controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_POETRY
+1
View File
@@ -207,6 +207,7 @@ func TestMapPackageManager(t *testing.T) {
{"bun", "bun", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_BUN}, {"bun", "bun", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_BUN},
{"pip", "pip", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_PIP}, {"pip", "pip", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_PIP},
{"pip3", "pip3", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_PIP}, {"pip3", "pip3", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_PIP},
{"pipx", "pipx", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_PIP},
{"poetry", "poetry", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_POETRY}, {"poetry", "poetry", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_POETRY},
{"uv", "uv", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_UV}, {"uv", "uv", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_UV},
{"unknown", "cargo", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_UNSPECIFIED}, {"unknown", "cargo", controltowerv1.PmgPackageManager_PMG_PACKAGE_MANAGER_UNSPECIFIED},
+1
View File
@@ -147,6 +147,7 @@ func main() {
cmd.AddCommand(pypi.NewPip3Command()) cmd.AddCommand(pypi.NewPip3Command())
cmd.AddCommand(pypi.NewUvCommand()) cmd.AddCommand(pypi.NewUvCommand())
cmd.AddCommand(pypi.NewPoetryCommand()) cmd.AddCommand(pypi.NewPoetryCommand())
cmd.AddCommand(executors.NewPipxCommand())
cmd.AddCommand(version.NewVersionCommand()) cmd.AddCommand(version.NewVersionCommand())
cmd.AddCommand(setup.NewSetupCommand()) cmd.AddCommand(setup.NewSetupCommand())
cmd.AddCommand(setup.NewRemoveCommand()) cmd.AddCommand(setup.NewRemoveCommand())
+222
View File
@@ -0,0 +1,222 @@
package packagemanager
import (
"io"
"slices"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/spf13/pflag"
)
type PypiPackageExecutorConfig struct {
CommandName string
InstallCommands []string
NonDownloadCommands []string
}
func DefaultPipxPackageExecutorConfig() PypiPackageExecutorConfig {
return PypiPackageExecutorConfig{
CommandName: "pipx",
InstallCommands: []string{"install", "inject", "run", "upgrade", "upgrade-all", "reinstall", "reinstall-all"},
NonDownloadCommands: []string{
"list", "uninstall", "uninstall-all", "completions", "uninject", "ensurepath", "environment",
},
}
}
type pypiPackageExecutor struct {
Config PypiPackageExecutorConfig
}
func NewPypiPackageExecutor(config PypiPackageExecutorConfig) (*pypiPackageExecutor, error) {
return &pypiPackageExecutor{
Config: config,
}, nil
}
var _ PackageManager = &pypiPackageExecutor{}
func (p *pypiPackageExecutor) Name() string {
return p.Config.CommandName
}
func (p *pypiPackageExecutor) Ecosystem() packagev1.Ecosystem {
return packagev1.Ecosystem_ECOSYSTEM_PYPI
}
func (p *pypiPackageExecutor) ParseCommand(args []string) (*ParsedCommand, error) {
if len(args) > 0 && args[0] == "pipx" {
args = args[1:]
}
command := Command{Exe: p.Config.CommandName, Args: args}
if len(args) < 1 {
return &ParsedCommand{Command: command}, nil
}
// pipx run <pkg> downloads and executes a package without globally installing it.
// We extract the package name so it can be audited before execution.
if args[0] == "run" {
return p.parseRunCommand(command, args[1:])
}
// pipx inject <target-venv> <pkg1> [<pkg2> ...] injects packages into an
// existing venv. The first positional arg is the target venv (already installed),
// not a package to audit — we skip it and only audit the injected packages.
if args[0] == "inject" {
return p.parseInjectCommand(command, args[1:])
}
var installCmdIndex = -1
for idx, arg := range args {
if slices.Contains(p.Config.InstallCommands, arg) {
installCmdIndex = idx
break
}
}
if installCmdIndex == -1 {
return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: IsFirstNonFlagArgInList(args, p.Config.NonDownloadCommands)}, nil
}
installArgs := args[installCmdIndex+1:]
flagSet := pflag.NewFlagSet("pipx install", pflag.ContinueOnError)
flagSet.ParseErrorsAllowlist.UnknownFlags = true
flagSet.SetOutput(io.Discard)
// Define known pipx install flags. We register flags that take values to prevent
// their values from being misidentified as package names, and boolean flags
// to prevent the flag itself from being treated as an unknown argument.
// registers --pip-args, --python, --spec so their values aren't picked up as packages
setupCommonPipxFlags(flagSet)
flagSet.Bool("force", false, "")
flagSet.Bool("include-deps", false, "")
flagSet.Bool("system-site-packages", false, "")
err := flagSet.Parse(installArgs)
if err != nil {
return &ParsedCommand{Command: command}, nil
}
packages := flagSet.Args()
return p.buildInstallTargets(command, packages)
}
// parseRunCommand handles `pipx run [flags] <package> [args...]`.
// Only the first positional argument is the package; the rest are arguments
// to the executed program.
func (p *pypiPackageExecutor) parseRunCommand(command Command, runArgs []string) (*ParsedCommand, error) {
if len(runArgs) == 0 {
return &ParsedCommand{Command: command}, nil
}
flagSet := pflag.NewFlagSet("pipx run", pflag.ContinueOnError)
flagSet.ParseErrorsAllowlist.UnknownFlags = true
flagSet.SetOutput(io.Discard)
// Define known pipx run flags. We register flags that take values to prevent
// their values from being misidentified as package names, and boolean flags
// to prevent the flag itself from being treated as an unknown argument.
// registers --pip-args, --python, --spec so their values aren't picked up as packages
_, _, specPkg := setupCommonPipxFlags(flagSet)
flagSet.Bool("no-cache", false, "")
err := flagSet.Parse(runArgs)
if err != nil {
return &ParsedCommand{Command: command}, nil
}
// If --spec is provided, that's the package to audit, not the positional arg
if *specPkg != "" {
return p.buildInstallTargets(command, []string{*specPkg})
}
packages := flagSet.Args()
if len(packages) == 0 {
return &ParsedCommand{Command: command}, nil
}
// Only the first positional arg is the package
return p.buildInstallTargets(command, []string{packages[0]})
}
// parseInjectCommand handles `pipx inject [flags] <target-venv> <pkg1> [<pkg2> ...]`.
// The first positional argument is the target venv (already installed, not audited).
// Subsequent positional arguments are the packages being injected.
func (p *pypiPackageExecutor) parseInjectCommand(command Command, injectArgs []string) (*ParsedCommand, error) {
if len(injectArgs) == 0 {
return &ParsedCommand{Command: command}, nil
}
flagSet := pflag.NewFlagSet("pipx inject", pflag.ContinueOnError)
flagSet.ParseErrorsAllowlist.UnknownFlags = true
flagSet.SetOutput(io.Discard)
// Define known pipx inject flags. We register flags that take values to prevent
// their values from being misidentified as package names, and boolean flags
// to prevent the flag itself from being treated as an unknown argument.
// registers --pip-args, --python, --spec so their values aren't picked up as packages
setupCommonPipxFlags(flagSet)
flagSet.Bool("force", false, "")
flagSet.Bool("include-apps", false, "")
flagSet.Bool("include-deps", false, "")
err := flagSet.Parse(injectArgs)
if err != nil {
return &ParsedCommand{Command: command}, nil
}
packages := flagSet.Args()
if len(packages) < 2 {
// Need at least target-venv + one package to inject
return &ParsedCommand{Command: command}, nil
}
// Skip the first positional arg (target venv), audit the rest
return p.buildInstallTargets(command, packages[1:])
}
// buildInstallTargets creates install targets from a list of package specifiers.
func (p *pypiPackageExecutor) buildInstallTargets(command Command, packages []string) (*ParsedCommand, error) {
var installTargets []*PackageInstallTarget
for _, pkg := range packages {
packageName, version, extras, err := pypiParsePackageInfo(pkg)
if err != nil {
return nil, ErrFailedToParsePackage.Wrap(err)
}
isExplicit := version != ""
version, err = pypiGetMatchingVersion(packageName, version)
if err != nil {
return nil, ErrFailedToResolveVersion.Wrap(err)
}
installTargets = append(installTargets, &PackageInstallTarget{
PackageVersion: &packagev1.PackageVersion{
Package: &packagev1.Package{
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_PYPI,
Name: packageName,
},
Version: version,
},
Extras: extras,
IsExplicitVersion: isExplicit,
})
}
return &ParsedCommand{
Command: command,
InstallTargets: installTargets,
IsManifestInstall: false,
}, nil
}
func setupCommonPipxFlags(flagSet *pflag.FlagSet) (pipArgs, pythonPath, specPkg *string) {
pipArgs = flagSet.String("pip-args", "", "")
pythonPath = flagSet.String("python", "", "")
specPkg = flagSet.String("spec", "", "")
return
}
+254
View File
@@ -0,0 +1,254 @@
package packagemanager
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestPipxExecutorParseCommand(t *testing.T) {
pm, err := NewPypiPackageExecutor(DefaultPipxPackageExecutorConfig())
assert.NoError(t, err)
cases := []struct {
name string
args []string
expectedManifest bool
expectedTargets int
expectedPackages []string
wantErr bool
}{
{
name: "pipx install simple package",
args: []string{"install", "black"},
expectedManifest: false,
expectedTargets: 1,
expectedPackages: []string{"black"},
},
{
name: "pipx install with specific version",
args: []string{"install", "black==22.3.0"},
expectedManifest: false,
expectedTargets: 1,
expectedPackages: []string{"black"},
},
{
name: "pipx install with --force flag",
args: []string{"install", "--force", "ruff"},
expectedManifest: false,
expectedTargets: 1,
expectedPackages: []string{"ruff"},
},
{
name: "pipx install with --pip-args flag",
args: []string{"install", "--pip-args", "--no-cache-dir", "black"},
expectedManifest: false,
expectedTargets: 1,
expectedPackages: []string{"black"},
},
{
name: "pipx install with --python flag",
args: []string{"install", "--python", "python3.11", "black"},
expectedManifest: false,
expectedTargets: 1,
expectedPackages: []string{"black"},
},
{
name: "pipx run simple package",
args: []string{"run", "cowsay", "hello"},
expectedManifest: false,
expectedTargets: 1,
expectedPackages: []string{"cowsay"},
},
{
name: "pipx run with --spec flag",
args: []string{"run", "--spec", "black==22.3.0", "black", "--check", "."},
expectedManifest: false,
expectedTargets: 1,
expectedPackages: []string{"black"},
},
{
name: "pipx run with --no-cache flag",
args: []string{"run", "--no-cache", "cowsay", "hello"},
expectedManifest: false,
expectedTargets: 1,
expectedPackages: []string{"cowsay"},
},
{
name: "pipx run bare (no package)",
args: []string{"run"},
expectedManifest: false,
expectedTargets: 0,
expectedPackages: []string{},
},
{
name: "pipx inject skips target venv",
args: []string{"inject", "poetry", "poetry-plugin-export"},
expectedManifest: false,
expectedTargets: 1,
expectedPackages: []string{"poetry-plugin-export"},
},
{
name: "pipx inject multiple packages",
args: []string{"inject", "myapp", "requests", "flask"},
expectedManifest: false,
expectedTargets: 2,
expectedPackages: []string{"requests", "flask"},
},
{
name: "pipx inject with --force flag",
args: []string{"inject", "--force", "poetry", "poetry-plugin-export"},
expectedManifest: false,
expectedTargets: 1,
expectedPackages: []string{"poetry-plugin-export"},
},
{
name: "pipx inject with --include-apps flag",
args: []string{"inject", "--include-apps", "myapp", "flask"},
expectedManifest: false,
expectedTargets: 1,
expectedPackages: []string{"flask"},
},
{
name: "pipx inject with --pip-args flag",
args: []string{"inject", "--pip-args", "--no-deps", "myapp", "flask"},
expectedManifest: false,
expectedTargets: 1,
expectedPackages: []string{"flask"},
},
{
name: "pipx inject only target venv (no packages)",
args: []string{"inject", "poetry"},
expectedManifest: false,
expectedTargets: 0,
expectedPackages: []string{},
},
{
name: "pipx inject bare (no args)",
args: []string{"inject"},
expectedManifest: false,
expectedTargets: 0,
expectedPackages: []string{},
},
{
name: "pipx list",
args: []string{"list"},
expectedManifest: false,
expectedTargets: 0,
expectedPackages: []string{},
},
{
name: "pipx uninstall",
args: []string{"uninstall", "black"},
expectedManifest: false,
expectedTargets: 0,
expectedPackages: []string{},
},
{
name: "bare pipx invocation",
args: []string{},
expectedManifest: false,
expectedTargets: 0,
expectedPackages: []string{},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result, err := pm.ParseCommand(tc.args)
if tc.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tc.expectedManifest, result.HasManifestInstall(), "HasManifestInstall mismatch")
assert.Equal(t, tc.expectedTargets, len(result.InstallTargets), "Number of install targets mismatch")
for i, expectedPkg := range tc.expectedPackages {
if i < len(result.InstallTargets) {
target := result.InstallTargets[i]
assert.Equal(t, expectedPkg, target.PackageVersion.Package.Name, "Package name mismatch for package %d", i)
}
}
})
}
}
func TestPipxExecutorProxyBehavior(t *testing.T) {
cases := []struct {
name string
command string
isKnownNonDownloadCmd bool
isInstallationCommand bool
}{
{
name: "pipx install — proxy runs",
command: "pipx install black",
isKnownNonDownloadCmd: false,
isInstallationCommand: true,
},
{
name: "pipx run — proxy runs",
command: "pipx run cowsay moo",
isKnownNonDownloadCmd: false,
isInstallationCommand: true,
},
{
name: "pipx inject — proxy runs",
command: "pipx inject myapp requests",
isKnownNonDownloadCmd: false,
isInstallationCommand: true,
},
{
name: "pipx upgrade — proxy runs (downloads newer version)",
command: "pipx upgrade black",
isKnownNonDownloadCmd: false,
isInstallationCommand: true,
},
{
name: "pipx upgrade-all — proxy runs (downloads newer versions)",
command: "pipx upgrade-all",
isKnownNonDownloadCmd: false,
isInstallationCommand: false,
},
{
name: "pipx list — proxy skipped",
command: "pipx list",
isKnownNonDownloadCmd: true,
isInstallationCommand: false,
},
{
name: "pipx uninstall — proxy skipped",
command: "pipx uninstall black",
isKnownNonDownloadCmd: true,
isInstallationCommand: false,
},
{
name: "pipx uninstall-all — proxy skipped",
command: "pipx uninstall-all",
isKnownNonDownloadCmd: true,
isInstallationCommand: false,
},
{
name: "pipx completions — proxy skipped",
command: "pipx completions",
isKnownNonDownloadCmd: true,
isInstallationCommand: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
pm, err := NewPypiPackageExecutor(DefaultPipxPackageExecutorConfig())
assert.NoError(t, err)
parsed, err := pm.ParseCommand(strings.Split(tc.command, " "))
assert.NoError(t, err)
assert.Equal(t, tc.isKnownNonDownloadCmd, parsed.IsKnownNonDownloadCommand)
assert.Equal(t, tc.isInstallationCommand, parsed.IsInstallationCommand())
assert.Equal(t, !tc.isKnownNonDownloadCmd, parsed.MayDownloadPackages())
})
}
}
+33
View File
@@ -0,0 +1,33 @@
name: pipx
description: Profile for pipx executor with write access to current directory
inherits: pypi-restrictive
package_managers:
- pipx
# pipx requires PTY access to work, especially for commands like:
# pipx run cowsay hello
#
# Explicitly setting this so that it is enabled even if we decide to turn this off
# in parent profiles in the future.
allow_pty: true
# pipx-executed tools may need to bind to localhost ports (e.g., dev servers)
allow_network_bind: true
filesystem:
allow_read:
# pipx installs and manages packages in ~/.local/pipx
- ${HOME}/.local/pipx/**
- ${HOME}/.local/bin/**
# Add write permissions for pipx-specific paths
allow_write:
- ${CWD}/**
- ${HOME}/.local/pipx/**
- ${HOME}/.local/bin/**
# Additional deny rules for extra security
deny_write:
- ${CWD}/.env
- ${CWD}/.env.*