From 59cc6e7f5e800628028f27b311ae4237cbc7e508 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Fri, 21 Aug 2026 14:50:46 +0100 Subject: [PATCH 1/2] Return Python distribution files from path lookups --- README.md | 1 + definitions/pip.yaml | 5 ++-- definitions/poetry.yaml | 5 ++-- definitions/uv.yaml | 5 ++-- extractor.go | 54 ++++++++++++++++++++++++++++++++++++++++ extractor_test.go | 55 +++++++++++++++++++++++++++++++++++++++++ generic_manager.go | 9 +++---- generic_manager_test.go | 46 ++++++++++++++++++++++++++++++++++ manager.go | 5 ++-- translator_test.go | 6 ++--- 10 files changed, 171 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index dc25e00..e50b705 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/definitions/pip.yaml b/definitions/pip.yaml index 0586ad5..f5533b5 100644 --- a/definitions/pip.yaml +++ b/definitions/pip.yaml @@ -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] diff --git a/definitions/poetry.yaml b/definitions/poetry.yaml index 9240225..f372168 100644 --- a/definitions/poetry.yaml +++ b/definitions/poetry.yaml @@ -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] diff --git a/definitions/uv.yaml b/definitions/uv.yaml index d93653c..bd16a2e 100644 --- a/definitions/uv.yaml +++ b/definitions/uv.yaml @@ -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] diff --git a/extractor.go b/extractor.go index b5f27e8..740d01b 100644 --- a/extractor.go +++ b/extractor.go @@ -5,6 +5,7 @@ import ( "fmt" "path/filepath" "regexp" + "slices" "strings" "github.com/git-pkgs/managers/definitions" @@ -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) } @@ -44,6 +47,57 @@ 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 + } + + 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 || !startsWithWhitespace(line) { + continue + } + file := strings.TrimSpace(line) + if file == "" { + continue + } + 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") diff --git a/extractor_test.go b/extractor_test.go index b00fe3f..995a72d 100644 --- a/extractor_test.go +++ b/extractor_test.go @@ -1,6 +1,7 @@ package managers import ( + "path/filepath" "testing" "github.com/git-pkgs/managers/definitions" @@ -85,6 +86,60 @@ 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 +` + 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{ diff --git a/generic_manager.go b/generic_manager.go index 7462122..5303d9e 100644 --- a/generic_manager.go +++ b/generic_manager.go @@ -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 } diff --git a/generic_manager_test.go b/generic_manager_test.go index 7e4a333..020dbf9 100644 --- a/generic_manager_test.go +++ b/generic_manager_test.go @@ -137,6 +137,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{ + "/venv/lib/python3.12/site-packages/requests-2.32.0.dist-info/licenses/LICENSE", + "/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", diff --git a/manager.go b/manager.go index 5d187f0..6bf78e6 100644 --- a/manager.go +++ b/manager.go @@ -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 diff --git a/translator_test.go b/translator_test.go index 85ef250..2a2fd83 100644 --- a/translator_test.go +++ b/translator_test.go @@ -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) } @@ -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) } @@ -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) } From 6378f2737af299c652d6e052a1e03f5d748eb25e Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Fri, 21 Aug 2026 15:36:18 +0100 Subject: [PATCH 2/2] Tighten Python distribution file extraction --- definitions/schema.go | 2 +- extractor.go | 5 ++++- extractor_test.go | 2 ++ generic_manager_test.go | 5 +++-- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/definitions/schema.go b/definitions/schema.go index 08d342b..9f3d252 100644 --- a/definitions/schema.go +++ b/definitions/schema.go @@ -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} diff --git a/extractor.go b/extractor.go index 740d01b..d417971 100644 --- a/extractor.go +++ b/extractor.go @@ -77,13 +77,16 @@ func extractPythonDistribution(output string) (string, []string, error) { inFiles = true continue } - if !inFiles || !startsWithWhitespace(line) { + 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)) } diff --git a/extractor_test.go b/extractor_test.go index 995a72d..2e5aa89 100644 --- a/extractor_test.go +++ b/extractor_test.go @@ -95,6 +95,8 @@ Files: 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", diff --git a/generic_manager_test.go b/generic_manager_test.go index 020dbf9..bf7b973 100644 --- a/generic_manager_test.go +++ b/generic_manager_test.go @@ -3,6 +3,7 @@ package managers import ( "context" "errors" + "path/filepath" "testing" "github.com/git-pkgs/managers/definitions" @@ -172,8 +173,8 @@ Files: t.Errorf("path = %q", result.Path) } wantFiles := []string{ - "/venv/lib/python3.12/site-packages/requests-2.32.0.dist-info/licenses/LICENSE", - "/venv/lib/python3.12/site-packages/requests/__init__.py", + 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)