Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ fmt.Println(result.Path) // "/path/to/project/node_modules/lodash"
```

The library handles extracting clean paths from various output formats (JSON, line-based, regex patterns). For managers with predictable locations (yarn, mix, shards), paths are computed from templates.
Python path results also include `result.Files`, resolved from `pip show --files`, because several distributions share one `site-packages` directory.

**Managers with path support:** npm, pnpm, yarn, bun, bundler, gem, pip, uv, poetry, conda, gomod, cargo, composer, brew, deno, nimble, opam, luarocks, conan, mix, shards, rebar3, renv

Expand Down
5 changes: 2 additions & 3 deletions definitions/pip.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,14 @@ commands:
1: error

path:
base: [show]
base: [show, -f]
args:
package: {position: 0, required: true}
exit_codes:
0: success
1: error
extract:
type: line_prefix
prefix: "Location: "
type: python_distribution

resolve:
base: [inspect]
Expand Down
5 changes: 2 additions & 3 deletions definitions/poetry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,14 @@ commands:

# poetry run pip show works inside the virtualenv
path:
base: [run, pip, show]
base: [run, pip, show, -f]
args:
package: {position: 0, required: true}
exit_codes:
0: success
1: error
extract:
type: line_prefix
prefix: "Location: "
type: python_distribution

resolve:
base: [show]
Expand Down
2 changes: 1 addition & 1 deletion definitions/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ type Command struct {
}

type Extract struct {
Type string `yaml:"type"` // raw, json, line_prefix, regex, json_array, template
Type string `yaml:"type"` // raw, json, line_prefix, regex, json_array, template, python_distribution
Field string `yaml:"field,omitempty"` // for json: field name to extract
Prefix string `yaml:"prefix,omitempty"` // for line_prefix: prefix to match
Pattern string `yaml:"pattern,omitempty"` // for regex: pattern with capture group; for template: path pattern with {package}
Expand Down
5 changes: 2 additions & 3 deletions definitions/uv.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,14 @@ commands:

# uv pip show works like pip show
path:
base: [pip, show]
base: [pip, show, -f]
args:
package: {position: 0, required: true, validate: pypi_package}
exit_codes:
0: success
1: error
extract:
type: line_prefix
prefix: "Location: "
type: python_distribution

resolve:
base: [tree]
Expand Down
57 changes: 57 additions & 0 deletions extractor.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"path/filepath"
"regexp"
"slices"
"strings"

"github.com/git-pkgs/managers/definitions"
Expand All @@ -29,6 +30,8 @@ func ExtractPath(output string, extract *definitions.Extract, pkg string) (strin
result, err = extractJSONArray(output, extract.ArrayField, extract.MatchField, extract.ExtractField, pkg)
case "template":
result, err = extractTemplate(extract.Pattern, pkg)
case "python_distribution":
result, _, err = extractPythonDistribution(output)
default:
return "", fmt.Errorf("unknown extract type: %s", extract.Type)
}
Expand All @@ -44,6 +47,60 @@ func ExtractPath(output string, extract *definitions.Extract, pkg string) (strin
return result, nil
}

func extractPathResult(output string, extract *definitions.Extract, pkg string) (*PathResult, error) {
if extract != nil && extract.Type == "python_distribution" {
path, files, err := extractPythonDistribution(output)
if err != nil {
return nil, err
}
return &PathResult{Path: path, Files: files}, nil
}
Comment on lines +51 to +57

path, err := ExtractPath(output, extract, pkg)
if err != nil {
return nil, err
}
return &PathResult{Path: path}, nil
}

func extractPythonDistribution(output string) (string, []string, error) {
location, err := extractLinePrefix(output, "Location: ")
if err != nil {
return "", nil, err
}

lines := strings.Split(output, "\n")
files := make([]string, 0)
inFiles := false
for _, line := range lines {
if strings.TrimSpace(line) == "Files:" {
inFiles = true
continue
}
if !inFiles {
continue
}
file := strings.TrimSpace(line)
if file == "" {
continue
}
if !startsWithWhitespace(line) {
break
}
if !filepath.IsAbs(file) {
file = filepath.Join(location, filepath.FromSlash(file))
}
files = append(files, filepath.Clean(file))
}
slices.Sort(files)
files = slices.Compact(files)
return location, files, nil
}

func startsWithWhitespace(value string) bool {
return strings.HasPrefix(value, " ") || strings.HasPrefix(value, "\t")
}

func extractJSON(output string, field string) (string, error) {
if field == "" {
return "", fmt.Errorf("json extraction requires field name")
Expand Down
57 changes: 57 additions & 0 deletions extractor_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package managers

import (
"path/filepath"
"testing"

"github.com/git-pkgs/managers/definitions"
Expand Down Expand Up @@ -85,6 +86,62 @@ Version: 2.28.1`
}
}

func TestExtractPathResult_PythonDistribution(t *testing.T) {
output := `Name: packaging
Version: 25.0
Location: /venv/lib/python3.12/site-packages
Files:
../../../bin/packaging-tool
packaging-25.0.dist-info/licenses/LICENSE
packaging/__init__.py
packaging/__init__.py
Installer: pip
ignored-content
`
result, err := extractPathResult(output, &definitions.Extract{
Type: "python_distribution",
}, "packaging")
if err != nil {
t.Fatalf("ExtractPathResult failed: %v", err)
}
if result.Path != "/venv/lib/python3.12/site-packages" {
t.Errorf("path = %q", result.Path)
}
wantFiles := []string{
filepath.Clean("/venv/bin/packaging-tool"),
filepath.Clean("/venv/lib/python3.12/site-packages/packaging-25.0.dist-info/licenses/LICENSE"),
filepath.Clean("/venv/lib/python3.12/site-packages/packaging/__init__.py"),
}
if !slicesEqual(result.Files, wantFiles) {
t.Errorf("files = %#v, want %#v", result.Files, wantFiles)
}

path, err := ExtractPath(output, &definitions.Extract{Type: "python_distribution"}, "packaging")
if err != nil {
t.Fatalf("ExtractPath failed: %v", err)
}
if path != result.Path {
t.Errorf("ExtractPath = %q, want %q", path, result.Path)
}
}

func TestExtractPathResult_PythonDistributionWithoutRecord(t *testing.T) {
output := `Name: example
Location: /venv/lib/python3.12/site-packages
Files:
Cannot locate RECORD or installed-files.txt
`
result, err := extractPathResult(output, &definitions.Extract{
Type: "python_distribution",
}, "example")
if err != nil {
t.Fatalf("ExtractPathResult failed: %v", err)
}
if len(result.Files) != 0 {
t.Errorf("files = %#v, want none", result.Files)
}
}

func TestExtractPath_Regex(t *testing.T) {
output := `Package path: /var/lib/gems/3.0.0/gems/rails-7.0.0`
result, err := ExtractPath(output, &definitions.Extract{
Expand Down
9 changes: 3 additions & 6 deletions generic_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,13 +222,10 @@ func (m *GenericManager) Path(ctx context.Context, pkg string) (*PathResult, err
extract = pathCmd.Extract
}

path, err := ExtractPath(result.Stdout, extract, pkg)
pathResult, err := extractPathResult(result.Stdout, extract, pkg)
if err != nil {
return &PathResult{Result: result}, err
}

return &PathResult{
Path: path,
Result: result,
}, nil
pathResult.Result = result
return pathResult, nil
}
47 changes: 47 additions & 0 deletions generic_manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package managers
import (
"context"
"errors"
"path/filepath"
"testing"

"github.com/git-pkgs/managers/definitions"
Expand Down Expand Up @@ -137,6 +138,52 @@ Requires: certifi, charset-normalizer`,
}
}

func TestGenericManager_Path_PythonDistribution(t *testing.T) {
def := &definitions.Definition{
Name: "pip",
Binary: "pip",
Commands: map[string]definitions.Command{
"path": {
Base: []string{"show", "-f"},
Args: map[string]definitions.Arg{
"package": {Position: 0, Required: true},
},
Extract: &definitions.Extract{Type: "python_distribution"},
},
},
Capabilities: []string{"path"},
}
runner := NewMockRunner()
runner.Results = []*Result{{
ExitCode: 0,
Stdout: `Name: requests
Location: /venv/lib/python3.12/site-packages
Files:
requests-2.32.0.dist-info/licenses/LICENSE
requests/__init__.py
`,
}}

mgr := newTestManager(def, runner)
result, err := mgr.Path(context.Background(), "requests")
if err != nil {
t.Fatalf("Path failed: %v", err)
}
if result.Path != "/venv/lib/python3.12/site-packages" {
t.Errorf("path = %q", result.Path)
}
wantFiles := []string{
filepath.Clean("/venv/lib/python3.12/site-packages/requests-2.32.0.dist-info/licenses/LICENSE"),
filepath.Clean("/venv/lib/python3.12/site-packages/requests/__init__.py"),
}
if !slicesEqual(result.Files, wantFiles) {
t.Errorf("files = %#v, want %#v", result.Files, wantFiles)
}
if result.Result != runner.Results[0] {
t.Error("underlying command result was not retained")
}
}

func TestGenericManager_Path_Template(t *testing.T) {
def := &definitions.Definition{
Name: "yarn",
Expand Down
5 changes: 3 additions & 2 deletions manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,9 @@ func (r *Result) Success() bool {
}

type PathResult struct {
Path string // extracted path to the package
Result *Result // underlying command result
Path string // extracted path to the package
Files []string // files owned by the package, when reported by the manager
Result *Result // underlying command result
}

type ExecContext int
Expand Down
6 changes: 3 additions & 3 deletions translator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3218,7 +3218,7 @@ func TestPipPath(t *testing.T) {
if err != nil {
t.Fatalf("BuildCommand failed: %v", err)
}
expected := []string{"pip", "show", "requests"}
expected := []string{"pip", "show", "-f", "requests"}
if !reflect.DeepEqual(cmd, expected) {
t.Errorf("got %v, want %v", cmd, expected)
}
Expand All @@ -3232,7 +3232,7 @@ func TestUvPath(t *testing.T) {
if err != nil {
t.Fatalf("BuildCommand failed: %v", err)
}
expected := []string{"uv", "pip", "show", "requests"}
expected := []string{"uv", "pip", "show", "-f", "requests"}
if !reflect.DeepEqual(cmd, expected) {
t.Errorf("got %v, want %v", cmd, expected)
}
Expand Down Expand Up @@ -3387,7 +3387,7 @@ func TestPoetryPath(t *testing.T) {
if err != nil {
t.Fatalf("BuildCommand failed: %v", err)
}
expected := []string{"poetry", "run", "pip", "show", "requests"}
expected := []string{"poetry", "run", "pip", "show", "-f", "requests"}
if !reflect.DeepEqual(cmd, expected) {
t.Errorf("got %v, want %v", cmd, expected)
}
Expand Down