Skip to content

Commit 52fa479

Browse files
authored
Feature: Add pipx origin (global Python packages) (#341)
1 parent b4c1920 commit 52fa479

17 files changed

Lines changed: 432 additions & 14 deletions

File tree

README.md

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ this package is compatible with the following plaforms and distributions:
4848
- [elementary OS](https://elementary.io/)
4949
- the 50 other arch and debian-based distros, as long as they have `pacman`, `apt`, `brew`,`dpkg`, or `opkg` installed
5050

51-
`qp` also runs on embedded linux systems, including meta-distributions like [yocto](https://www.yoctoproject.org/) that use `opkg` (`.ipk` packages) or `apt`/`dpkg` (`.deb` packages). `rpm` support is currently on the way!
51+
`qp` also detects and queries application-specific package managers like `pipx` for isolated python applications, expanding package discovery beyond traditional system package management.
52+
53+
`qp` supports embedded linux systems, including meta-distributions like [yocto](https://www.yoctoproject.org/) that use `opkg` (`.ipk` packages) or `apt`/`dpkg` (`.deb` packages). `rpm` support is currently on the way!
5254

5355
more distros and non-linux platforms are planned!
5456

@@ -57,7 +59,10 @@ more distros and non-linux platforms are planned!
5759
* list installed packages across supported systems
5860
* compatible with macOS, arch, debian, openwrt, and over 60 distros
5961
* * supports multiple ecosystems:
60-
* pacman, dpkg/apt, opkg, brew (formulae, bottles, casks)
62+
* system package managers:
63+
* pacman, apt/dpkg, opkg, brew
64+
* * application package managers:
65+
* pipx
6166
* query packages using an expressive query language
6267
* supports full boolean logic (`and`, `or`, `not`, grouping)
6368
* supports fuzzy and strict matching
@@ -291,14 +296,13 @@ qp [command] [args] [options]
291296
- `name` - package name
292297
- `reason` - installation reason (explicit/dependency)
293298
- `version` - installed package version
294-
- `origin` - the package ecosystem or source the package belongs to (e.g., pacman); reflects which package manager or backend maintains it
299+
- `origin` - the package ecosystem or source the package belongs to (e.g., brew, pipx); reflects which package manager or backend maintains it
295300
- `arch` - architecture the package was built for (e.g., x86_64, aarch64, any)
296301
- `license` - package software license
297302
- `description` - package description
298303
- `url` - the URL of the official site of the software being packaged
299304
- `validation` - package integrity validation method (e.g., sha256, pgp)
300-
- `pkgtype` - package type (pkg, split, debug, src)
301-
- ***note**: older packages may have no pkgtype if built before pacman introduced XDATA
305+
- `pkgtype` - package type (specific to each origin, some origins have no pkgtype)
302306
- `pkgbase` - name of the base package used to group split packages; for non-split packages, it is the same as the package name.
303307
- `packager` - person/entity who built the package (if available)
304308
- `groups` - list of package groups or categories (e.g., base, gnome, xfce4)
@@ -310,6 +314,24 @@ qp [command] [args] [options]
310314
- `optional-for` - list of packages that optionally depend on the package (optionally dependent)
311315
- `provides` - list of alternative package names or shared libraries provided by package
312316

317+
## package types by origin
318+
319+
the `pkgtype` field indicates the type or category of package within each ecosystem. different origins use different package type classifications:
320+
321+
| origin | supported package types | description |
322+
|--------|------------------------|-------------|
323+
| pacman | `pkg`, `split`, `debug`, `src` | package build type |
324+
| brew | `formula`, `cask` | formulae are command-line tools, casks are GUI applications |
325+
| deb | none | debian packages do not have a pkgtype classification |
326+
| opkg | none | openwrt packages do not have a pkgtype classification |
327+
| pipx | none | pipx packages do not have a pkgtype classification |
328+
329+
**notes:**
330+
- pacman's pkgtype comes from the package's XDATA field introduced in newer pacman versions
331+
- older packages may not have this field populated
332+
- brew distinguishes between formulae (CLI tools/libraries) and casks (GUI applications)
333+
- deb, opkg, and pipx origins do not implement package type classifications and will show empty pkgtype values
334+
313335
### querying with `where`
314336

315337
the `where` (short: `w`) command is the core of qp's flexible query system.
@@ -410,7 +432,6 @@ qp w not arch=x86_64
410432
qp w q has:depends or has:required-by p and not reason=explicit
411433
```
412434

413-
414435
#### field types
415436

416437
| field type | description |
@@ -778,6 +799,11 @@ output format:
778799
qp where no:depends
779800
```
780801

802+
39. show all packages installed via pipx
803+
```
804+
qp where origin=pipx
805+
```
806+
781807
## license
782808

783809
this project is licensed under GPL-3.0-only.

internal/config/help.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,10 @@ Tips:
9898
qp --no-headers select name,size
9999
100100
- JSON output:
101-
qp select name,version,size --json
101+
qp select name,version,size --output=json
102+
103+
- Key-Value output (ideal for selecting all fields):
104+
qp s all --output=kv
102105
103106
- Quote arguments with spaces or special characters:
104107
qp where description="for tree-sitter"

internal/consts/origins.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ const (
55
OriginPacman = "pacman"
66
OriginDeb = "deb"
77
OriginOpkg = "opkg"
8+
OriginPipx = "pipx"
89
)

internal/origins/drivers/brew/fetch_casks.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"os"
66
"path/filepath"
7+
"qp/internal/origins/shared"
78
"qp/internal/origins/worker"
89
"qp/internal/pkgdata"
910
"sync"
@@ -66,7 +67,7 @@ func fetchCasks(
6667
errChan,
6768
errGroup,
6869
func(pkg *pkgdata.PkgInfo) (*pkgdata.PkgInfo, error) {
69-
size, err := getInstallSize(filepath.Join(caskroomRoot, pkg.Name, pkg.Version))
70+
size, err := shared.GetInstallSize(filepath.Join(caskroomRoot, pkg.Name, pkg.Version))
7071
if err == nil {
7172
pkg.Size = size
7273
}

internal/origins/drivers/brew/fetch_formulae.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package brew
22

33
import (
44
"path/filepath"
5+
"qp/internal/origins/shared"
56
"qp/internal/origins/worker"
67
"qp/internal/pkgdata"
78
"sync"
@@ -76,7 +77,7 @@ func fetchFormulae(
7677
errGroup,
7778
func(pkg *pkgdata.PkgInfo) (*pkgdata.PkgInfo, error) {
7879
versionPath := filepath.Join(prefix, cellarSubPath, pkg.Name, pkg.Version)
79-
if size, err := getInstallSize(versionPath); err == nil {
80+
if size, err := shared.GetInstallSize(versionPath); err == nil {
8081
pkg.Size = size
8182
}
8283

internal/origins/drivers/deb/parser.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"qp/internal/consts"
8-
"qp/internal/origins/formats/debstyle"
8+
"qp/internal/origins/shared/debstyle"
99
"qp/internal/origins/worker"
1010
"qp/internal/pkgdata"
1111
"strconv"

internal/origins/drivers/opkg/parser.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import (
77
"os"
88
"path/filepath"
99
"qp/internal/consts"
10-
"qp/internal/origins/formats/debstyle"
10+
"qp/internal/origins/shared/debstyle"
1111
"qp/internal/origins/worker"
1212
"qp/internal/pkgdata"
1313
"strconv"
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package pipx
2+
3+
const (
4+
defaultVenvRoot = ".local/pipx/venvs"
5+
otherVenvRoot = ".local/share/pipx/venvs"
6+
7+
fieldName = "Name"
8+
fieldVersion = "Version"
9+
fieldSummary = "Summary"
10+
fieldLicense = "License"
11+
fieldHomepage = "Home-page"
12+
fieldProjectUrl = "Project-Url"
13+
subfieldHomepage = "Homepage"
14+
fieldTag = "Tag"
15+
16+
anyArch = "any"
17+
universalArch = "universal"
18+
19+
dotDistInfo = ".dist-info"
20+
21+
pipxHomeEnv = "PIPX_HOME"
22+
homeEnv = "HOME"
23+
)
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package pipx
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
)
10+
11+
func findVenvRoot() (string, error) {
12+
var venvRootPath string
13+
14+
if custom := os.Getenv(pipxHomeEnv); custom != "" {
15+
venvRootPath = filepath.Join(custom, "venvs")
16+
_, err := os.Stat(venvRootPath)
17+
if err == nil {
18+
return venvRootPath, nil
19+
}
20+
}
21+
22+
home, err := os.UserHomeDir()
23+
if err != nil {
24+
home = os.Getenv(homeEnv)
25+
}
26+
27+
possibleRoots := []string{defaultVenvRoot, otherVenvRoot}
28+
for _, root := range possibleRoots {
29+
venvRootPath = filepath.Join(home, root)
30+
_, err := os.Stat(venvRootPath)
31+
if err == nil {
32+
return venvRootPath, nil
33+
}
34+
}
35+
36+
return "", errors.New("no pipx venv root found")
37+
}
38+
39+
func findVersionPath(libRoot string) (string, error) {
40+
entries, err := os.ReadDir(libRoot)
41+
if err != nil {
42+
return "", err
43+
}
44+
45+
for _, entry := range entries {
46+
if entry.IsDir() && len(entry.Name()) > 6 && entry.Name()[:6] == "python" {
47+
return filepath.Join(libRoot, entry.Name()), nil
48+
}
49+
}
50+
51+
return "", fmt.Errorf("no pythonX.Y found under %s", libRoot)
52+
}
53+
54+
func findDistPath(sitePkgsPath string, name string) (string, error) {
55+
distInfoMatcher := name + "-*" + dotDistInfo
56+
matches, _ := filepath.Glob(filepath.Join(sitePkgsPath, distInfoMatcher, "METADATA"))
57+
if len(matches) > 0 {
58+
return matches[0], nil
59+
}
60+
61+
return "", fmt.Errorf("no .dist-info/METADATA found for: %s", name)
62+
}
63+
64+
func inferArchitecture(sitePkgsPath string) (string, error) {
65+
distInfoMatcher := "*" + dotDistInfo
66+
matches, err := filepath.Glob(filepath.Join(sitePkgsPath, distInfoMatcher, "WHEEL"))
67+
if err != nil {
68+
return "", fmt.Errorf("found no .dist-info directories in %s: %v", sitePkgsPath, err)
69+
}
70+
71+
bestMatch := anyArch
72+
73+
for _, match := range matches {
74+
arch, err := parseWheelFile(match)
75+
if err != nil {
76+
continue
77+
}
78+
79+
parts := strings.Split(arch, "-")
80+
suffix := parts[len(parts)-1]
81+
82+
if suffix != anyArch {
83+
if !strings.Contains(suffix, universalArch) {
84+
return suffix, nil
85+
}
86+
87+
bestMatch = suffix
88+
}
89+
}
90+
91+
return bestMatch, nil
92+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package pipx
2+
3+
import (
4+
"qp/internal/consts"
5+
"qp/internal/origins/shared"
6+
"qp/internal/pkgdata"
7+
)
8+
9+
type PipxDriver struct {
10+
venvRoot string
11+
}
12+
13+
func (d *PipxDriver) Name() string {
14+
return consts.OriginPipx
15+
}
16+
17+
func (d *PipxDriver) Detect() bool {
18+
venvRoot, err := findVenvRoot()
19+
if err != nil {
20+
return false
21+
}
22+
23+
d.venvRoot = venvRoot
24+
return true
25+
}
26+
27+
func (d *PipxDriver) Load() ([]*pkgdata.PkgInfo, error) {
28+
return fetchPackages(d.venvRoot, d.Name())
29+
}
30+
31+
func (d *PipxDriver) ResolveDeps(pkgs []*pkgdata.PkgInfo) ([]*pkgdata.PkgInfo, error) {
32+
return pkgs, nil
33+
}
34+
35+
func (d *PipxDriver) LoadCache(path string) ([]*pkgdata.PkgInfo, error) {
36+
return pkgdata.LoadProtoCache(path)
37+
}
38+
39+
func (d *PipxDriver) SaveCache(cacheRoot string, pkgs []*pkgdata.PkgInfo) error {
40+
return pkgdata.SaveProtoCache(cacheRoot, pkgs)
41+
}
42+
43+
func (d *PipxDriver) IsCacheStale(cacheMtime int64) (bool, error) {
44+
return shared.BfsStale(d.venvRoot, cacheMtime, 2)
45+
}

0 commit comments

Comments
 (0)