pkgwho 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pkgwho/__about__.py +4 -0
- pkgwho/__init__.py +3 -0
- pkgwho/cli.py +808 -0
- pkgwho/shell_config.py +209 -0
- pkgwho-0.1.1.dist-info/METADATA +141 -0
- pkgwho-0.1.1.dist-info/RECORD +9 -0
- pkgwho-0.1.1.dist-info/WHEEL +4 -0
- pkgwho-0.1.1.dist-info/entry_points.txt +2 -0
- pkgwho-0.1.1.dist-info/licenses/LICENSE.txt +9 -0
pkgwho/__about__.py
ADDED
pkgwho/__init__.py
ADDED
pkgwho/cli.py
ADDED
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026-present Toshihiro Kamiya <kamiya@mbj.nifty.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
"""Command line interface for identifying package ownership."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shlex
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from functools import lru_cache
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Sequence
|
|
20
|
+
|
|
21
|
+
from pkgwho.__about__ import __version__
|
|
22
|
+
from pkgwho.shell_config import PathSetup, find_path_setup
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Result:
|
|
27
|
+
command: str
|
|
28
|
+
executable: str | None
|
|
29
|
+
realpath: str | None
|
|
30
|
+
manager: str
|
|
31
|
+
package: str | None = None
|
|
32
|
+
uninstall: str | None = None
|
|
33
|
+
note: str | None = None
|
|
34
|
+
matches: list[PackageMatch] = field(default_factory=list)
|
|
35
|
+
path_setup: list[PathSetup] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class PackageMatch:
|
|
40
|
+
manager: str
|
|
41
|
+
package: str
|
|
42
|
+
uninstall: str
|
|
43
|
+
display_name: str | None = None
|
|
44
|
+
evidence: str = "name match"
|
|
45
|
+
section: str | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class FontMatch:
|
|
50
|
+
family: str
|
|
51
|
+
style: str
|
|
52
|
+
fullname: str
|
|
53
|
+
file: str
|
|
54
|
+
packages: list[PackageMatch] = field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def search_fonts(query: str) -> list[FontMatch] | None:
|
|
58
|
+
"""Find installed Fontconfig faces and identify their file owners."""
|
|
59
|
+
output = _run(["fc-list", "--format", "%{family}\t%{style}\t%{fullname}\t%{file}\n"])
|
|
60
|
+
if output is None:
|
|
61
|
+
return None
|
|
62
|
+
needle = _normalize_name(query)
|
|
63
|
+
matches = []
|
|
64
|
+
owners: dict[str, list[PackageMatch]] = {}
|
|
65
|
+
seen: set[tuple[str, str, str, str]] = set()
|
|
66
|
+
for line in output.splitlines():
|
|
67
|
+
fields = line.split("\t", 3)
|
|
68
|
+
if len(fields) != 4 or not fields[3]:
|
|
69
|
+
continue
|
|
70
|
+
family, style, fullname, filename = fields
|
|
71
|
+
if not any(needle in _normalize_name(name) for name in (family, fullname)):
|
|
72
|
+
continue
|
|
73
|
+
key = (family, style, fullname, filename)
|
|
74
|
+
if key in seen:
|
|
75
|
+
continue
|
|
76
|
+
seen.add(key)
|
|
77
|
+
if filename not in owners:
|
|
78
|
+
path = Path(filename)
|
|
79
|
+
try:
|
|
80
|
+
realpath = path.resolve(strict=True)
|
|
81
|
+
except OSError:
|
|
82
|
+
realpath = path
|
|
83
|
+
paths = (path,) if path == realpath else (path, realpath)
|
|
84
|
+
packages = []
|
|
85
|
+
package = _dpkg_owner(paths)
|
|
86
|
+
if package:
|
|
87
|
+
packages.append(PackageMatch("dpkg", package, "sudo apt remove " + shlex.quote(package), evidence="dpkg path record"))
|
|
88
|
+
package = _brew_owner(paths)
|
|
89
|
+
if package:
|
|
90
|
+
packages.append(PackageMatch("brew", package, "brew uninstall " + shlex.quote(package), evidence="Homebrew file record"))
|
|
91
|
+
owners[filename] = packages
|
|
92
|
+
matches.append(FontMatch(family, style, fullname, filename, owners[filename]))
|
|
93
|
+
return sorted(matches, key=lambda item: (item.family.casefold(), item.style.casefold(), item.file))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _font_result(query: str, matches: list[FontMatch] | None) -> dict[str, object]:
|
|
97
|
+
return {
|
|
98
|
+
"font": query,
|
|
99
|
+
"note": "Fontconfig fc-list is unavailable" if matches is None else ("no installed font matched" if not matches else None),
|
|
100
|
+
"matches": [
|
|
101
|
+
{"family": m.family, "style": m.style, "fullname": m.fullname, "file": m.file,
|
|
102
|
+
"packages": [{"manager": p.manager, "package": p.package, "uninstall": p.uninstall, "evidence": p.evidence} for p in m.packages]}
|
|
103
|
+
for m in matches or []
|
|
104
|
+
],
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _format_fonts(query: str, matches: list[FontMatch] | None, no_limit: bool) -> str:
|
|
109
|
+
lines = [query]
|
|
110
|
+
if matches is None:
|
|
111
|
+
lines.append(" note: Fontconfig fc-list is unavailable")
|
|
112
|
+
elif not matches:
|
|
113
|
+
lines.append(" note: no installed font matched")
|
|
114
|
+
else:
|
|
115
|
+
for match in matches if no_limit else matches[:20]:
|
|
116
|
+
lines.append(" font: " + (match.fullname or match.family))
|
|
117
|
+
lines.append(" family: " + match.family)
|
|
118
|
+
lines.append(" style: " + match.style)
|
|
119
|
+
lines.append(" file: " + match.file)
|
|
120
|
+
if match.packages:
|
|
121
|
+
lines.extend(_format_matches(match.packages, True, path_records=True))
|
|
122
|
+
else:
|
|
123
|
+
lines.append(" package: unknown")
|
|
124
|
+
if not no_limit and len(matches) > 20:
|
|
125
|
+
lines.append(" ... {} more matches; use --no-limit to display all matches".format(len(matches) - 20))
|
|
126
|
+
return "\n".join(lines)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _run(args: Sequence[str]) -> str | None:
|
|
130
|
+
"""Run a package-manager query without invoking a shell."""
|
|
131
|
+
try:
|
|
132
|
+
result = subprocess.run(
|
|
133
|
+
args,
|
|
134
|
+
check=False,
|
|
135
|
+
capture_output=True,
|
|
136
|
+
text=True,
|
|
137
|
+
encoding="utf-8",
|
|
138
|
+
errors="replace",
|
|
139
|
+
timeout=10,
|
|
140
|
+
)
|
|
141
|
+
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
|
142
|
+
return None
|
|
143
|
+
if result.returncode != 0:
|
|
144
|
+
return None
|
|
145
|
+
return result.stdout.strip()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _resolve(command: str) -> Path | None:
|
|
149
|
+
"""Resolve a command name or an explicitly supplied path."""
|
|
150
|
+
expanded = Path(os.path.expandvars(os.path.expanduser(command)))
|
|
151
|
+
if expanded.is_absolute() or "/" in command:
|
|
152
|
+
if expanded.exists() and expanded.is_file():
|
|
153
|
+
return expanded.absolute()
|
|
154
|
+
return None
|
|
155
|
+
found = shutil.which(command)
|
|
156
|
+
return Path(found).absolute() if found else None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _dpkg_owner(paths: Sequence[Path]) -> str | None:
|
|
160
|
+
for path in paths:
|
|
161
|
+
output = _run(["dpkg-query", "-S", str(path)])
|
|
162
|
+
if not output:
|
|
163
|
+
continue
|
|
164
|
+
for line in output.splitlines():
|
|
165
|
+
# dpkg-query emits one "package: path" record per owner.
|
|
166
|
+
package, separator, owned_path = line.partition(": ")
|
|
167
|
+
if separator and owned_path == str(path):
|
|
168
|
+
return package[:-4] if package.endswith(":any") else package
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@lru_cache(maxsize=1)
|
|
173
|
+
def _snap_packages() -> set[str]:
|
|
174
|
+
output = _run(["snap", "list"])
|
|
175
|
+
if not output:
|
|
176
|
+
return set()
|
|
177
|
+
rows = output.splitlines()[1:]
|
|
178
|
+
return {row.split()[0] for row in rows if row.split()}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _snap_owner(executable: Path) -> str | None:
|
|
182
|
+
# Snap command aliases live in /snap/bin even when their target resolves
|
|
183
|
+
# to /usr/bin/snap, so inspect the original path before its realpath.
|
|
184
|
+
if executable.parent != Path("/snap/bin"):
|
|
185
|
+
return None
|
|
186
|
+
candidate = executable.name
|
|
187
|
+
packages = _snap_packages()
|
|
188
|
+
if candidate in packages:
|
|
189
|
+
return candidate
|
|
190
|
+
# Additional applications from a snap are exposed as <snap>.<app>.
|
|
191
|
+
matches = [name for name in packages if candidate.startswith(name + ".")]
|
|
192
|
+
return max(matches, key=len) if matches else None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@lru_cache(maxsize=1)
|
|
196
|
+
def _flatpak_apps() -> set[str]:
|
|
197
|
+
output = _run(["flatpak", "list", "--app", "--columns=application"])
|
|
198
|
+
return set(output.splitlines()) if output else set()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@lru_cache(maxsize=1)
|
|
202
|
+
def _pipx_tools() -> list[tuple[str, set[str]]]:
|
|
203
|
+
output = _run(["pipx", "list", "--output", "json"])
|
|
204
|
+
if not output:
|
|
205
|
+
return []
|
|
206
|
+
try:
|
|
207
|
+
data = json.loads(output)
|
|
208
|
+
except json.JSONDecodeError:
|
|
209
|
+
return []
|
|
210
|
+
|
|
211
|
+
tools: list[tuple[str, set[str]]] = []
|
|
212
|
+
venvs = data.get("venvs", {})
|
|
213
|
+
if not isinstance(venvs, dict):
|
|
214
|
+
return tools
|
|
215
|
+
for environment, record in venvs.items():
|
|
216
|
+
if not isinstance(record, dict):
|
|
217
|
+
continue
|
|
218
|
+
metadata = record.get("metadata", record)
|
|
219
|
+
if not isinstance(metadata, dict):
|
|
220
|
+
continue
|
|
221
|
+
main_package = metadata.get("main_package", {})
|
|
222
|
+
if not isinstance(main_package, dict):
|
|
223
|
+
continue
|
|
224
|
+
package = main_package.get("package") or environment
|
|
225
|
+
apps = set()
|
|
226
|
+
for app in main_package.get("apps", []):
|
|
227
|
+
if isinstance(app, str):
|
|
228
|
+
apps.add(Path(app).name)
|
|
229
|
+
dependency_apps = main_package.get("apps_of_dependencies", {})
|
|
230
|
+
if isinstance(dependency_apps, dict):
|
|
231
|
+
for app_list in dependency_apps.values():
|
|
232
|
+
if isinstance(app_list, list):
|
|
233
|
+
apps.update(Path(app).name for app in app_list if isinstance(app, str))
|
|
234
|
+
tools.append((str(package), apps))
|
|
235
|
+
return tools
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@lru_cache(maxsize=1)
|
|
239
|
+
def _uv_tools() -> list[tuple[str, set[str]]]:
|
|
240
|
+
output = _run(["uv", "tool", "list", "--show-paths"])
|
|
241
|
+
if not output:
|
|
242
|
+
return []
|
|
243
|
+
|
|
244
|
+
tools: list[tuple[str, set[str]]] = []
|
|
245
|
+
current_package: str | None = None
|
|
246
|
+
current_apps: set[str] = set()
|
|
247
|
+
for line in output.splitlines():
|
|
248
|
+
header = re.match(r"^(\S+)\s+v\S+", line)
|
|
249
|
+
if header:
|
|
250
|
+
if current_package is not None:
|
|
251
|
+
tools.append((current_package, current_apps))
|
|
252
|
+
current_package = header.group(1)
|
|
253
|
+
current_apps = set()
|
|
254
|
+
continue
|
|
255
|
+
app = re.match(r"^\s+-\s+(\S+)\s*$", line)
|
|
256
|
+
if app and "/" not in app.group(1):
|
|
257
|
+
current_apps.add(Path(app.group(1)).name)
|
|
258
|
+
if current_package is not None:
|
|
259
|
+
tools.append((current_package, current_apps))
|
|
260
|
+
return tools
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@lru_cache(maxsize=1)
|
|
264
|
+
def _cargo_tools() -> list[tuple[str, set[str]]]:
|
|
265
|
+
output = _run(["cargo", "install", "--list"])
|
|
266
|
+
if not output:
|
|
267
|
+
return []
|
|
268
|
+
|
|
269
|
+
tools: list[tuple[str, set[str]]] = []
|
|
270
|
+
current_package: str | None = None
|
|
271
|
+
current_bins: set[str] = set()
|
|
272
|
+
for line in output.splitlines():
|
|
273
|
+
header = re.match(r"^(.+?)\s+v[^\s:]+:", line)
|
|
274
|
+
if header:
|
|
275
|
+
if current_package is not None:
|
|
276
|
+
tools.append((current_package, current_bins))
|
|
277
|
+
current_package = header.group(1)
|
|
278
|
+
current_bins = set()
|
|
279
|
+
continue
|
|
280
|
+
binary = re.match(r"^\s+([^\s]+)\s*$", line)
|
|
281
|
+
if binary:
|
|
282
|
+
current_bins.add(binary.group(1))
|
|
283
|
+
if current_package is not None:
|
|
284
|
+
tools.append((current_package, current_bins))
|
|
285
|
+
return tools
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@lru_cache(maxsize=1)
|
|
289
|
+
def _brew_packages() -> list[tuple[str, str]]:
|
|
290
|
+
packages: list[tuple[str, str]] = []
|
|
291
|
+
for kind, args in (
|
|
292
|
+
("formula", ["brew", "list", "--formula"]),
|
|
293
|
+
("cask", ["brew", "list", "--cask"]),
|
|
294
|
+
):
|
|
295
|
+
output = _run(args)
|
|
296
|
+
if output:
|
|
297
|
+
packages.extend((kind, line.strip()) for line in output.splitlines() if line.strip())
|
|
298
|
+
return packages
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@lru_cache(maxsize=1)
|
|
302
|
+
def _brew_owner(paths: tuple[Path, ...]) -> str | None:
|
|
303
|
+
output = _run(["brew", "list", "--formula", "--verbose"])
|
|
304
|
+
if not output:
|
|
305
|
+
return None
|
|
306
|
+
expected = {path.resolve() for path in paths}
|
|
307
|
+
for line in output.splitlines():
|
|
308
|
+
candidate = Path(line.strip())
|
|
309
|
+
if not candidate.is_absolute():
|
|
310
|
+
continue
|
|
311
|
+
try:
|
|
312
|
+
if candidate.resolve() in expected:
|
|
313
|
+
parts = candidate.parts
|
|
314
|
+
if "Cellar" in parts:
|
|
315
|
+
index = parts.index("Cellar")
|
|
316
|
+
if index + 1 < len(parts):
|
|
317
|
+
return parts[index + 1]
|
|
318
|
+
except OSError:
|
|
319
|
+
continue
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _pipx_owner(executable: Path, realpath: Path) -> str | None:
|
|
324
|
+
expected_root = Path(
|
|
325
|
+
os.environ.get("PIPX_HOME", Path.home() / ".local/share/pipx")
|
|
326
|
+
).expanduser()
|
|
327
|
+
try:
|
|
328
|
+
is_pipx_path = realpath.is_relative_to(expected_root)
|
|
329
|
+
except (OSError, ValueError):
|
|
330
|
+
is_pipx_path = False
|
|
331
|
+
if not is_pipx_path:
|
|
332
|
+
return None
|
|
333
|
+
for package, apps in _pipx_tools():
|
|
334
|
+
if executable.name in apps:
|
|
335
|
+
return package
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _uv_owner(executable: Path, realpath: Path) -> str | None:
|
|
340
|
+
expected_root = Path(
|
|
341
|
+
os.environ.get("UV_TOOL_DIR", Path.home() / ".local/share/uv/tools")
|
|
342
|
+
).expanduser()
|
|
343
|
+
try:
|
|
344
|
+
is_uv_path = realpath.is_relative_to(expected_root)
|
|
345
|
+
except (OSError, ValueError):
|
|
346
|
+
is_uv_path = False
|
|
347
|
+
if not is_uv_path:
|
|
348
|
+
return None
|
|
349
|
+
for package, apps in _uv_tools():
|
|
350
|
+
if executable.name in apps:
|
|
351
|
+
return package
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _cargo_owner(executable: Path, realpath: Path) -> str | None:
|
|
356
|
+
cargo_home = Path(os.environ.get("CARGO_HOME", Path.home() / ".cargo")).expanduser()
|
|
357
|
+
try:
|
|
358
|
+
is_cargo_path = executable.is_relative_to(cargo_home / "bin") or realpath.is_relative_to(
|
|
359
|
+
cargo_home / "bin"
|
|
360
|
+
)
|
|
361
|
+
except (OSError, ValueError):
|
|
362
|
+
is_cargo_path = False
|
|
363
|
+
if not is_cargo_path:
|
|
364
|
+
return None
|
|
365
|
+
for package, binaries in _cargo_tools():
|
|
366
|
+
if executable.name in binaries:
|
|
367
|
+
return package
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _search_installed_packages(query: str) -> list[PackageMatch]:
|
|
372
|
+
"""Find installed packages whose identifier or display name contains query."""
|
|
373
|
+
needle = _normalize_name(query)
|
|
374
|
+
matches: list[PackageMatch] = []
|
|
375
|
+
|
|
376
|
+
output = _run(
|
|
377
|
+
["dpkg-query", "-W", "-f=${binary:Package}\t${Version}\t${Section}\n"]
|
|
378
|
+
)
|
|
379
|
+
if output:
|
|
380
|
+
for line in output.splitlines():
|
|
381
|
+
fields = line.split("\t", 2)
|
|
382
|
+
if len(fields) >= 2 and needle in _normalize_name(fields[0]):
|
|
383
|
+
package = fields[0]
|
|
384
|
+
section = fields[2].strip() if len(fields) > 2 else ""
|
|
385
|
+
matches.append(
|
|
386
|
+
PackageMatch(
|
|
387
|
+
"dpkg",
|
|
388
|
+
package,
|
|
389
|
+
"sudo apt remove " + shlex.quote(package),
|
|
390
|
+
section=section or None,
|
|
391
|
+
)
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
for package in sorted(_snap_packages()):
|
|
395
|
+
if needle in _normalize_name(package):
|
|
396
|
+
matches.append(
|
|
397
|
+
PackageMatch(
|
|
398
|
+
"snap",
|
|
399
|
+
package,
|
|
400
|
+
"sudo snap remove " + shlex.quote(package),
|
|
401
|
+
)
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
flatpak_output = _run(
|
|
405
|
+
["flatpak", "list", "--app", "--columns=application,name"]
|
|
406
|
+
)
|
|
407
|
+
if flatpak_output:
|
|
408
|
+
for line in flatpak_output.splitlines():
|
|
409
|
+
fields = line.split("\t", 1)
|
|
410
|
+
if len(fields) == 1:
|
|
411
|
+
fields = line.split(None, 1)
|
|
412
|
+
app_id = fields[0].strip()
|
|
413
|
+
display_name = fields[1].strip() if len(fields) > 1 else ""
|
|
414
|
+
if needle in _normalize_name(app_id) or needle in _normalize_name(display_name):
|
|
415
|
+
matches.append(
|
|
416
|
+
PackageMatch(
|
|
417
|
+
"flatpak",
|
|
418
|
+
app_id,
|
|
419
|
+
"flatpak uninstall " + shlex.quote(app_id),
|
|
420
|
+
display_name or None,
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
for manager, tools, uninstall_prefix in (
|
|
425
|
+
("pipx", _pipx_tools(), "pipx uninstall "),
|
|
426
|
+
("uv", _uv_tools(), "uv tool uninstall "),
|
|
427
|
+
("cargo", _cargo_tools(), "cargo uninstall "),
|
|
428
|
+
):
|
|
429
|
+
for package, commands in tools:
|
|
430
|
+
matching_command = next(
|
|
431
|
+
(name for name in sorted(commands) if needle in _normalize_name(name)),
|
|
432
|
+
None,
|
|
433
|
+
)
|
|
434
|
+
if needle in _normalize_name(package) or matching_command:
|
|
435
|
+
matches.append(
|
|
436
|
+
PackageMatch(
|
|
437
|
+
manager,
|
|
438
|
+
package,
|
|
439
|
+
uninstall_prefix + shlex.quote(package),
|
|
440
|
+
display_name=matching_command,
|
|
441
|
+
)
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
for kind, package in _brew_packages():
|
|
445
|
+
if needle in _normalize_name(package):
|
|
446
|
+
uninstall = "brew uninstall "
|
|
447
|
+
if kind == "cask":
|
|
448
|
+
uninstall += "--cask "
|
|
449
|
+
matches.append(
|
|
450
|
+
PackageMatch(
|
|
451
|
+
"brew",
|
|
452
|
+
package,
|
|
453
|
+
uninstall + shlex.quote(package),
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
return matches
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _normalize_name(value: str) -> str:
|
|
461
|
+
"""Normalize package names for case- and punctuation-insensitive matching."""
|
|
462
|
+
return "".join(character for character in value.casefold() if character.isalnum())
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _flatpak_owner(executable: Path) -> str | None:
|
|
466
|
+
# Flatpak exports launchers under exports/bin/<application-id>. A normal
|
|
467
|
+
# command provided inside an app is not exposed on the host PATH.
|
|
468
|
+
parts = executable.parts
|
|
469
|
+
try:
|
|
470
|
+
marker = parts.index("exports")
|
|
471
|
+
except ValueError:
|
|
472
|
+
return None
|
|
473
|
+
if marker + 2 >= len(parts) or parts[marker + 1] != "bin":
|
|
474
|
+
return None
|
|
475
|
+
candidate = executable.name
|
|
476
|
+
return candidate if candidate in _flatpak_apps() else None
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def inspect(command: str) -> Result:
|
|
480
|
+
executable = _resolve(command)
|
|
481
|
+
if executable is None:
|
|
482
|
+
return Result(command, None, None, "unknown", note="command not found")
|
|
483
|
+
|
|
484
|
+
try:
|
|
485
|
+
realpath = executable.resolve(strict=True)
|
|
486
|
+
except OSError:
|
|
487
|
+
realpath = executable
|
|
488
|
+
|
|
489
|
+
is_manager_launcher = executable.parent == Path("/snap/bin") or (
|
|
490
|
+
"exports" in executable.parts and "bin" in executable.parts
|
|
491
|
+
)
|
|
492
|
+
paths = (
|
|
493
|
+
(executable,)
|
|
494
|
+
if is_manager_launcher or executable == realpath
|
|
495
|
+
else (executable, realpath)
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
direct_matches: list[PackageMatch] = []
|
|
499
|
+
package = _dpkg_owner(paths)
|
|
500
|
+
if package:
|
|
501
|
+
direct_matches.append(
|
|
502
|
+
PackageMatch(
|
|
503
|
+
"dpkg",
|
|
504
|
+
package,
|
|
505
|
+
"sudo apt remove " + shlex.quote(package),
|
|
506
|
+
evidence="dpkg path record",
|
|
507
|
+
)
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
package = _snap_owner(executable)
|
|
511
|
+
if package:
|
|
512
|
+
direct_matches.append(
|
|
513
|
+
PackageMatch(
|
|
514
|
+
"snap",
|
|
515
|
+
package,
|
|
516
|
+
"sudo snap remove " + shlex.quote(package),
|
|
517
|
+
evidence="snap command path",
|
|
518
|
+
)
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
package = _flatpak_owner(executable)
|
|
522
|
+
if package:
|
|
523
|
+
direct_matches.append(
|
|
524
|
+
PackageMatch(
|
|
525
|
+
"flatpak",
|
|
526
|
+
package,
|
|
527
|
+
"flatpak uninstall " + shlex.quote(package),
|
|
528
|
+
evidence="Flatpak export path",
|
|
529
|
+
)
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
package = _pipx_owner(executable, realpath)
|
|
533
|
+
if package:
|
|
534
|
+
direct_matches.append(
|
|
535
|
+
PackageMatch(
|
|
536
|
+
"pipx",
|
|
537
|
+
package,
|
|
538
|
+
"pipx uninstall " + shlex.quote(package),
|
|
539
|
+
evidence="pipx exposed app",
|
|
540
|
+
)
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
package = _uv_owner(executable, realpath)
|
|
544
|
+
if package:
|
|
545
|
+
direct_matches.append(
|
|
546
|
+
PackageMatch(
|
|
547
|
+
"uv",
|
|
548
|
+
package,
|
|
549
|
+
"uv tool uninstall " + shlex.quote(package),
|
|
550
|
+
evidence="uv exposed app",
|
|
551
|
+
)
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
package = _cargo_owner(executable, realpath)
|
|
555
|
+
if package:
|
|
556
|
+
direct_matches.append(
|
|
557
|
+
PackageMatch(
|
|
558
|
+
"cargo",
|
|
559
|
+
package,
|
|
560
|
+
"cargo uninstall " + shlex.quote(package),
|
|
561
|
+
evidence="Cargo installed binary",
|
|
562
|
+
)
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
package = _brew_owner(paths)
|
|
566
|
+
if package:
|
|
567
|
+
direct_matches.append(
|
|
568
|
+
PackageMatch(
|
|
569
|
+
"brew",
|
|
570
|
+
package,
|
|
571
|
+
"brew uninstall " + shlex.quote(package),
|
|
572
|
+
evidence="Homebrew file record",
|
|
573
|
+
)
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
matches = direct_matches
|
|
577
|
+
matches.sort(key=lambda match: (match.manager, match.package))
|
|
578
|
+
note = None
|
|
579
|
+
if not matches:
|
|
580
|
+
note = (
|
|
581
|
+
"no supported package manager claims this executable; "
|
|
582
|
+
"it may have been installed manually or by another installer"
|
|
583
|
+
)
|
|
584
|
+
path_setup = find_path_setup(executable.parent)
|
|
585
|
+
return Result(
|
|
586
|
+
command,
|
|
587
|
+
str(executable),
|
|
588
|
+
str(realpath),
|
|
589
|
+
matches[0].manager if matches else "unknown",
|
|
590
|
+
matches[0].package if matches else None,
|
|
591
|
+
matches[0].uninstall if matches else None,
|
|
592
|
+
note,
|
|
593
|
+
matches,
|
|
594
|
+
path_setup,
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _format_matches(
|
|
599
|
+
matches: Sequence[PackageMatch],
|
|
600
|
+
no_limit: bool,
|
|
601
|
+
*,
|
|
602
|
+
path_records: bool = False,
|
|
603
|
+
) -> list[str]:
|
|
604
|
+
visible = list(matches)
|
|
605
|
+
if not path_records:
|
|
606
|
+
visible.sort(key=lambda match: (match.manager, match.package.casefold()))
|
|
607
|
+
shown = visible if no_limit else visible[:20]
|
|
608
|
+
lines = []
|
|
609
|
+
for match in shown:
|
|
610
|
+
evidence = " ({})".format(match.evidence) if path_records else ""
|
|
611
|
+
description = " - {}{}: {}".format(match.manager, evidence, match.package)
|
|
612
|
+
if match.section:
|
|
613
|
+
description += " [section: {}]".format(match.section)
|
|
614
|
+
if match.display_name and match.display_name.casefold() != match.package.casefold():
|
|
615
|
+
description += " ({})".format(match.display_name)
|
|
616
|
+
lines.append(description)
|
|
617
|
+
lines.append(" uninstall: " + match.uninstall)
|
|
618
|
+
remaining = len(visible) - len(shown)
|
|
619
|
+
if remaining:
|
|
620
|
+
lines.append(
|
|
621
|
+
" ... {} more matches; use --no-limit to display all selected matches".format(remaining)
|
|
622
|
+
)
|
|
623
|
+
return lines
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _json_result(
|
|
627
|
+
command: str,
|
|
628
|
+
result: Result,
|
|
629
|
+
matches: Sequence[PackageMatch],
|
|
630
|
+
) -> dict[str, object]:
|
|
631
|
+
visible = list(matches)
|
|
632
|
+
visible.sort(key=lambda match: (match.evidence == "name match", match.manager, match.package))
|
|
633
|
+
primary = visible[0] if visible else None
|
|
634
|
+
note = result.note
|
|
635
|
+
|
|
636
|
+
return {
|
|
637
|
+
"command": command,
|
|
638
|
+
"executable": result.executable,
|
|
639
|
+
"realpath": result.realpath,
|
|
640
|
+
"manager": primary.manager if primary else "unknown",
|
|
641
|
+
"package": primary.package if primary else None,
|
|
642
|
+
"uninstall": primary.uninstall if primary else None,
|
|
643
|
+
"note": note,
|
|
644
|
+
"matches": [
|
|
645
|
+
{
|
|
646
|
+
"manager": match.manager,
|
|
647
|
+
"package": match.package,
|
|
648
|
+
"uninstall": match.uninstall,
|
|
649
|
+
"evidence": match.evidence,
|
|
650
|
+
"display_name": match.display_name,
|
|
651
|
+
"section": match.section,
|
|
652
|
+
}
|
|
653
|
+
for match in visible
|
|
654
|
+
],
|
|
655
|
+
"path_setup": [
|
|
656
|
+
{"file": item.file, "line": item.line, "entry": item.entry}
|
|
657
|
+
for item in result.path_setup
|
|
658
|
+
],
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _format(result: Result, no_limit: bool = False) -> str:
|
|
663
|
+
lines = [result.command]
|
|
664
|
+
if result.executable is None:
|
|
665
|
+
lines.append(" manager: unknown")
|
|
666
|
+
lines.append(" note: " + (result.note or "command not found"))
|
|
667
|
+
return "\n".join(lines)
|
|
668
|
+
lines.extend(
|
|
669
|
+
[
|
|
670
|
+
" PATH resolution:",
|
|
671
|
+
" executable: " + result.executable,
|
|
672
|
+
" realpath: " + (result.realpath or result.executable),
|
|
673
|
+
]
|
|
674
|
+
)
|
|
675
|
+
if result.matches:
|
|
676
|
+
lines.append(" package manager records:")
|
|
677
|
+
lines.extend(_format_matches(result.matches, no_limit, path_records=True))
|
|
678
|
+
else:
|
|
679
|
+
lines.append(" manager: unknown")
|
|
680
|
+
if result.note:
|
|
681
|
+
lines.append(" note: " + result.note)
|
|
682
|
+
if result.path_setup:
|
|
683
|
+
lines.append(" PATH entry sources:")
|
|
684
|
+
for item in result.path_setup:
|
|
685
|
+
lines.append(f" {item.file}:{item.line} {item.entry}")
|
|
686
|
+
lines.append(
|
|
687
|
+
" note: these sources show where PATH is recorded, not how "
|
|
688
|
+
"the executable was installed"
|
|
689
|
+
)
|
|
690
|
+
else:
|
|
691
|
+
lines.append(
|
|
692
|
+
" PATH entry sources: none found in checked shell config files or "
|
|
693
|
+
"recognized Fish universal paths"
|
|
694
|
+
)
|
|
695
|
+
return "\n".join(lines)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _format_search(
|
|
699
|
+
command: str,
|
|
700
|
+
matches: Sequence[PackageMatch],
|
|
701
|
+
no_limit: bool = False,
|
|
702
|
+
) -> str:
|
|
703
|
+
lines = [command]
|
|
704
|
+
if not matches:
|
|
705
|
+
lines.extend(
|
|
706
|
+
[
|
|
707
|
+
" manager: unknown",
|
|
708
|
+
" note: no installed dpkg, snap, flatpak, pipx, uv, Cargo, or Homebrew package matched",
|
|
709
|
+
]
|
|
710
|
+
)
|
|
711
|
+
else:
|
|
712
|
+
lines.append(" package matches:")
|
|
713
|
+
lines.extend(_format_matches(matches, no_limit))
|
|
714
|
+
return "\n".join(lines)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
718
|
+
parser = argparse.ArgumentParser(
|
|
719
|
+
prog="pkgwho",
|
|
720
|
+
description=(
|
|
721
|
+
"Search commands, installed packages, and Fontconfig fonts."
|
|
722
|
+
),
|
|
723
|
+
)
|
|
724
|
+
parser.add_argument("--version", action="version", version="%(prog)s " + __version__)
|
|
725
|
+
parser.add_argument(
|
|
726
|
+
"--type", choices=("command", "package", "font", "all"), default="all",
|
|
727
|
+
help="what to search (default: all)",
|
|
728
|
+
)
|
|
729
|
+
parser.add_argument(
|
|
730
|
+
"commands", nargs="+", metavar="QUERY",
|
|
731
|
+
help="command, package, or font search text",
|
|
732
|
+
)
|
|
733
|
+
parser.add_argument(
|
|
734
|
+
"--no-limit",
|
|
735
|
+
action="store_true",
|
|
736
|
+
help="display every selected match without the default 20-match limit",
|
|
737
|
+
)
|
|
738
|
+
parser.add_argument(
|
|
739
|
+
"--json",
|
|
740
|
+
action="store_true",
|
|
741
|
+
help="output all results as a JSON array",
|
|
742
|
+
)
|
|
743
|
+
parser.add_argument(
|
|
744
|
+
"-o",
|
|
745
|
+
"--output",
|
|
746
|
+
metavar="FILE",
|
|
747
|
+
dest="output_file",
|
|
748
|
+
help="write output to FILE instead of standard output",
|
|
749
|
+
)
|
|
750
|
+
args = parser.parse_args(argv)
|
|
751
|
+
output: list[Any] = []
|
|
752
|
+
unresolved = False
|
|
753
|
+
for command in args.commands:
|
|
754
|
+
results: dict[str, Any] = {}
|
|
755
|
+
found = False
|
|
756
|
+
if args.type in ("command", "all"):
|
|
757
|
+
result = inspect(command)
|
|
758
|
+
found = result.executable is not None
|
|
759
|
+
results["command"] = (
|
|
760
|
+
_json_result(command, result, result.matches)
|
|
761
|
+
if args.json else _format(result, args.no_limit)
|
|
762
|
+
)
|
|
763
|
+
if args.type in ("package", "all"):
|
|
764
|
+
matches = _search_installed_packages(command)
|
|
765
|
+
found = found or bool(matches)
|
|
766
|
+
results["package"] = (
|
|
767
|
+
{"query": command, "matches": [
|
|
768
|
+
{"manager": m.manager, "package": m.package,
|
|
769
|
+
"uninstall": m.uninstall, "evidence": m.evidence,
|
|
770
|
+
"display_name": m.display_name, "section": m.section}
|
|
771
|
+
for m in matches
|
|
772
|
+
], "note": None if matches else "no installed package matched"}
|
|
773
|
+
if args.json else _format_search(command, matches, args.no_limit)
|
|
774
|
+
)
|
|
775
|
+
if args.type in ("font", "all"):
|
|
776
|
+
font_matches = search_fonts(command)
|
|
777
|
+
found = found or bool(font_matches)
|
|
778
|
+
results["font"] = (
|
|
779
|
+
_font_result(command, font_matches)
|
|
780
|
+
if args.json else _format_fonts(command, font_matches, args.no_limit)
|
|
781
|
+
)
|
|
782
|
+
unresolved = unresolved or not found
|
|
783
|
+
if args.type == "all":
|
|
784
|
+
if args.json:
|
|
785
|
+
output.append({"query": command, **results})
|
|
786
|
+
else:
|
|
787
|
+
output.append("\n\n".join(
|
|
788
|
+
name.capitalize() + " search:\n" + results[name]
|
|
789
|
+
for name in ("command", "package", "font")
|
|
790
|
+
))
|
|
791
|
+
else:
|
|
792
|
+
output.append(results[args.type])
|
|
793
|
+
if args.json:
|
|
794
|
+
rendered_output = json.dumps(output, ensure_ascii=False, indent=2)
|
|
795
|
+
else:
|
|
796
|
+
rendered_output = "\n\n".join(output)
|
|
797
|
+
if args.output_file is not None:
|
|
798
|
+
try:
|
|
799
|
+
Path(args.output_file).write_text(rendered_output + "\n", encoding="utf-8")
|
|
800
|
+
except OSError as exc:
|
|
801
|
+
parser.error("could not write output file {!r}: {}".format(args.output_file, exc))
|
|
802
|
+
else:
|
|
803
|
+
print(rendered_output)
|
|
804
|
+
return 1 if unresolved else 0
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
if __name__ == "__main__":
|
|
808
|
+
sys.exit(main())
|
pkgwho/shell_config.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Static discovery of PATH entries in common user shell configuration files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Mapping, Sequence
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class PathSetup:
|
|
15
|
+
"""A shell configuration line that adds an executable directory to PATH."""
|
|
16
|
+
|
|
17
|
+
file: str
|
|
18
|
+
line: int
|
|
19
|
+
entry: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _configuration_files(home: Path, env: Mapping[str, str]) -> list[tuple[str, Path]]:
|
|
23
|
+
"""Return supported user configuration files in a stable shell order."""
|
|
24
|
+
zdotdir = Path(env.get("ZDOTDIR") or home).expanduser().absolute()
|
|
25
|
+
xdg_config_home = (
|
|
26
|
+
Path(env.get("XDG_CONFIG_HOME") or home / ".config").expanduser().absolute()
|
|
27
|
+
)
|
|
28
|
+
fish_dir = xdg_config_home / "fish"
|
|
29
|
+
|
|
30
|
+
candidates: list[tuple[str, Path]] = [
|
|
31
|
+
("bash", home / ".bashrc"),
|
|
32
|
+
("bash", home / ".bash_profile"),
|
|
33
|
+
("bash", home / ".bash_login"),
|
|
34
|
+
("bash", home / ".profile"),
|
|
35
|
+
]
|
|
36
|
+
candidates.append(("fish", fish_dir / "config.fish"))
|
|
37
|
+
try:
|
|
38
|
+
candidates.extend(
|
|
39
|
+
("fish", path)
|
|
40
|
+
for path in sorted(fish_dir.joinpath("conf.d").glob("*.fish"))
|
|
41
|
+
)
|
|
42
|
+
except OSError:
|
|
43
|
+
pass
|
|
44
|
+
candidates.extend(
|
|
45
|
+
("zsh", zdotdir / name)
|
|
46
|
+
for name in (".zshenv", ".zprofile", ".zshrc", ".zlogin")
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
unique: dict[str, tuple[str, Path]] = {}
|
|
50
|
+
for shell, candidate in candidates:
|
|
51
|
+
key = os.path.abspath(os.fspath(candidate))
|
|
52
|
+
unique.setdefault(key, (shell, candidate))
|
|
53
|
+
return list(unique.values())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _expand_path_entry(value: str, home: Path) -> str | None:
|
|
57
|
+
"""Expand only literal home-directory forms; never evaluate shell code."""
|
|
58
|
+
value = value.strip().rstrip(";")
|
|
59
|
+
if not value:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
quote: str | None = None
|
|
63
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
64
|
+
quote = value[0]
|
|
65
|
+
value = value[1:-1]
|
|
66
|
+
else:
|
|
67
|
+
if value.startswith("\"") or value.startswith("'"):
|
|
68
|
+
quote = value[0]
|
|
69
|
+
value = value[1:]
|
|
70
|
+
if value.endswith("\"") or value.endswith("'"):
|
|
71
|
+
value = value[:-1]
|
|
72
|
+
|
|
73
|
+
# Single quotes suppress both tilde and variable expansion in these shells.
|
|
74
|
+
if quote != "'":
|
|
75
|
+
if value == "~":
|
|
76
|
+
value = str(home)
|
|
77
|
+
elif value.startswith("~/"):
|
|
78
|
+
value = str(home / value[2:])
|
|
79
|
+
value = value.replace("${HOME}", str(home)).replace("$HOME", str(home))
|
|
80
|
+
|
|
81
|
+
if not os.path.isabs(value) or "$" in value or "~" in value:
|
|
82
|
+
return None
|
|
83
|
+
return os.path.normpath(value)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _path_values(line: str, shell: str) -> Sequence[str]:
|
|
87
|
+
"""Extract literal path candidates from common Bash/Zsh and Fish forms."""
|
|
88
|
+
try:
|
|
89
|
+
tokens = shlex.split(line, comments=True, posix=False)
|
|
90
|
+
except ValueError:
|
|
91
|
+
return ()
|
|
92
|
+
if not tokens:
|
|
93
|
+
return ()
|
|
94
|
+
|
|
95
|
+
values: list[str] = []
|
|
96
|
+
if shell in {"bash", "zsh"}:
|
|
97
|
+
assignment_tokens = tokens[1:] if tokens[0] == "export" else tokens[:1]
|
|
98
|
+
for token in assignment_tokens:
|
|
99
|
+
if token.startswith("PATH=") or token.startswith("PATH+="):
|
|
100
|
+
assignment_value = token.split("=", 1)[1]
|
|
101
|
+
values.extend(assignment_value.split(":"))
|
|
102
|
+
|
|
103
|
+
if shell == "fish":
|
|
104
|
+
if tokens[0] == "set" and "PATH" in tokens[1:]:
|
|
105
|
+
path_index = tokens.index("PATH", 1)
|
|
106
|
+
flags = tokens[1:path_index]
|
|
107
|
+
exported = "--export" in flags or any(
|
|
108
|
+
flag.startswith("-") and "x" in flag[1:] for flag in flags
|
|
109
|
+
)
|
|
110
|
+
if exported:
|
|
111
|
+
for token in tokens[path_index + 1 :]:
|
|
112
|
+
values.extend(token.split(":"))
|
|
113
|
+
|
|
114
|
+
if tokens[0] == "fish_add_path":
|
|
115
|
+
values.extend(token for token in tokens[1:] if not token.startswith("-"))
|
|
116
|
+
|
|
117
|
+
return values
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _fish_user_paths(fish_dir: Path, home: Path) -> list[PathSetup]:
|
|
121
|
+
"""Read Fish v3 universal path records without executing shell config."""
|
|
122
|
+
variables_file = fish_dir / "fish_variables"
|
|
123
|
+
if not variables_file.is_file():
|
|
124
|
+
return []
|
|
125
|
+
try:
|
|
126
|
+
lines = variables_file.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
127
|
+
except OSError:
|
|
128
|
+
return []
|
|
129
|
+
|
|
130
|
+
if "# VERSION: 3.0" not in lines:
|
|
131
|
+
return []
|
|
132
|
+
|
|
133
|
+
record = re.compile(
|
|
134
|
+
r"^SETUVAR(?:\s+--\S+)*\s+fish_user_paths:(.*)$"
|
|
135
|
+
)
|
|
136
|
+
escape = re.compile(rb"\\x([0-9a-fA-F]{2})")
|
|
137
|
+
latest: tuple[int, list[str]] | None = None
|
|
138
|
+
for line_number, line in enumerate(lines, start=1):
|
|
139
|
+
match = record.match(line)
|
|
140
|
+
if match is None:
|
|
141
|
+
continue
|
|
142
|
+
raw_value = match.group(1).encode("utf-8")
|
|
143
|
+
decoded = escape.sub(
|
|
144
|
+
lambda item: bytes((int(item.group(1), 16),)), raw_value
|
|
145
|
+
)
|
|
146
|
+
paths: list[str] = []
|
|
147
|
+
for raw_path in decoded.split(b"\x1e"):
|
|
148
|
+
value = raw_path.decode("utf-8", errors="replace")
|
|
149
|
+
expanded = _expand_path_entry(value, home)
|
|
150
|
+
if expanded is not None:
|
|
151
|
+
paths.append(expanded)
|
|
152
|
+
latest = (line_number, paths)
|
|
153
|
+
|
|
154
|
+
if latest is None:
|
|
155
|
+
return []
|
|
156
|
+
line_number, paths = latest
|
|
157
|
+
return [
|
|
158
|
+
PathSetup(
|
|
159
|
+
file=os.path.abspath(os.fspath(variables_file)),
|
|
160
|
+
line=line_number,
|
|
161
|
+
entry=f"Fish persistent PATH entry (fish_user_paths): {path}",
|
|
162
|
+
)
|
|
163
|
+
for path in paths
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def find_path_setup(
|
|
168
|
+
executable_directory: Path,
|
|
169
|
+
*,
|
|
170
|
+
env: Mapping[str, str] | None = None,
|
|
171
|
+
) -> list[PathSetup]:
|
|
172
|
+
"""Find config lines that statically add ``executable_directory`` to PATH."""
|
|
173
|
+
current_env = os.environ if env is None else env
|
|
174
|
+
home = Path(current_env.get("HOME") or Path.home()).expanduser().absolute()
|
|
175
|
+
target = os.path.normpath(os.fspath(executable_directory.absolute()))
|
|
176
|
+
matches: list[PathSetup] = []
|
|
177
|
+
|
|
178
|
+
config_files = _configuration_files(home, current_env)
|
|
179
|
+
for shell, config_file in config_files:
|
|
180
|
+
try:
|
|
181
|
+
lines = config_file.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
182
|
+
except OSError:
|
|
183
|
+
continue
|
|
184
|
+
for line_number, line in enumerate(lines, start=1):
|
|
185
|
+
for value in _path_values(line, shell):
|
|
186
|
+
expanded = _expand_path_entry(value, home)
|
|
187
|
+
if expanded == target:
|
|
188
|
+
matches.append(
|
|
189
|
+
PathSetup(
|
|
190
|
+
file=os.path.abspath(os.fspath(config_file)),
|
|
191
|
+
line=line_number,
|
|
192
|
+
entry=line.strip(),
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
xdg_config_home = Path(
|
|
198
|
+
current_env.get("XDG_CONFIG_HOME") or home / ".config"
|
|
199
|
+
).expanduser().absolute()
|
|
200
|
+
fish_dir = xdg_config_home / "fish"
|
|
201
|
+
target = os.path.normpath(os.fspath(executable_directory.absolute()))
|
|
202
|
+
fish_entry_prefix = "Fish persistent PATH entry (fish_user_paths): "
|
|
203
|
+
matches.extend(
|
|
204
|
+
item
|
|
205
|
+
for item in _fish_user_paths(fish_dir, home)
|
|
206
|
+
if item.entry.removeprefix(fish_entry_prefix) == target
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return matches
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pkgwho
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Identify the packages that provide commands and fonts on Linux
|
|
5
|
+
Project-URL: Documentation, https://github.com/tos-kamiya/pkgwho#readme
|
|
6
|
+
Project-URL: Issues, https://github.com/tos-kamiya/pkgwho/issues
|
|
7
|
+
Project-URL: Source, https://github.com/tos-kamiya/pkgwho
|
|
8
|
+
Project-URL: Changelog, https://github.com/tos-kamiya/pkgwho/blob/main/release-notes.md
|
|
9
|
+
Author-email: Toshihiro Kamiya <kamiya@mbj.nifty.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE.txt
|
|
12
|
+
Keywords: command,font,linux,package manager,package ownership
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
24
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
25
|
+
Classifier: Topic :: System :: Installation/Setup
|
|
26
|
+
Classifier: Topic :: System :: Systems Administration
|
|
27
|
+
Classifier: Topic :: Utilities
|
|
28
|
+
Requires-Python: >=3.10
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: mypy>=1.0.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# pkgwho
|
|
35
|
+
|
|
36
|
+
Find which package manager installed a command or font on Linux, or search
|
|
37
|
+
installed packages by name. pkgwho checks dpkg/apt, Snap, Flatpak, pipx, uv
|
|
38
|
+
tools, Cargo, and Homebrew, and suggests how to remove packages. It never
|
|
39
|
+
uninstalls anything automatically.
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
Install directly from GitHub with [pipx](https://pipx.pypa.io/):
|
|
44
|
+
|
|
45
|
+
```console
|
|
46
|
+
pipx install git+https://github.com/tos-kamiya/pkgwho.git
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
Pass one or more search terms. By default, pkgwho searches commands, packages,
|
|
52
|
+
and fonts for each term:
|
|
53
|
+
|
|
54
|
+
```console
|
|
55
|
+
pkgwho git snap flatpak
|
|
56
|
+
pkgwho /usr/local/bin/my-command
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The exit status is nonzero if any query has no result in the selected search
|
|
60
|
+
types. In combined mode, a query succeeds if any of the searches finds a result.
|
|
61
|
+
|
|
62
|
+
### `--type {command,package,font,all}`
|
|
63
|
+
|
|
64
|
+
Choose what to search. The default is `all`.
|
|
65
|
+
|
|
66
|
+
| Type | Search |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| `command` | Resolve a command in `PATH` or a file path and identify its package owner. |
|
|
69
|
+
| `package` | Find installed package names by case-insensitive partial match, including libraries. |
|
|
70
|
+
| `font` | Find installed font family or full names by case- and punctuation-insensitive partial match. |
|
|
71
|
+
| `all` | Search all three independently. A package may appear in more than one result. |
|
|
72
|
+
|
|
73
|
+
```console
|
|
74
|
+
pkgwho --type command git
|
|
75
|
+
pkgwho --type package chrome
|
|
76
|
+
pkgwho --type font "Noto Sans"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Command searches report `unknown` when no supported manager claims the file.
|
|
80
|
+
They also report matching PATH entries in Bash, Zsh, and Fish configuration,
|
|
81
|
+
including Fish's `fish_user_paths`. These entries are not proof of installation;
|
|
82
|
+
configuration files are read but never executed or modified.
|
|
83
|
+
|
|
84
|
+
Font searches require Fontconfig's `fc-list`. Results include the font file and,
|
|
85
|
+
when found in the dpkg or Homebrew formula database, its package and suggested
|
|
86
|
+
uninstall command. Unowned files are still shown. An unavailable `fc-list` is
|
|
87
|
+
reported and causes a nonzero exit status in font-only mode.
|
|
88
|
+
|
|
89
|
+
### `--no-limit`
|
|
90
|
+
|
|
91
|
+
Text output shows up to 20 matches per search. Use `--no-limit` to display all
|
|
92
|
+
matches:
|
|
93
|
+
|
|
94
|
+
```console
|
|
95
|
+
pkgwho --type package nvidia --no-limit
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### `--json`
|
|
99
|
+
|
|
100
|
+
Emit a JSON array for one or more queries:
|
|
101
|
+
|
|
102
|
+
```console
|
|
103
|
+
pkgwho git unknown-tool --json
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
In combined mode, each array element contains `query`, `command`, `package`,
|
|
107
|
+
and `font` fields. In single-type mode, each element contains that search's
|
|
108
|
+
result directly. JSON includes all matches without the text output's default
|
|
109
|
+
20-match limit.
|
|
110
|
+
|
|
111
|
+
### `-o FILE`, `--output FILE`
|
|
112
|
+
|
|
113
|
+
Write the selected output format to a file instead of standard output. Existing
|
|
114
|
+
files are overwritten:
|
|
115
|
+
|
|
116
|
+
```console
|
|
117
|
+
pkgwho git --json --output results.json
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### `-h`, `--help`
|
|
121
|
+
|
|
122
|
+
Show command-line usage and exit.
|
|
123
|
+
|
|
124
|
+
### `--version`
|
|
125
|
+
|
|
126
|
+
Show the installed pkgwho version and exit.
|
|
127
|
+
|
|
128
|
+
## Supported managers
|
|
129
|
+
|
|
130
|
+
- **dpkg/apt**: queries dpkg's file ownership database.
|
|
131
|
+
- **Snap**: checks commands exposed in `/snap/bin` against installed snaps.
|
|
132
|
+
- **Flatpak**: recognizes application launchers exported in Flatpak's
|
|
133
|
+
`exports/bin` directories.
|
|
134
|
+
- **pipx**: maps exposed applications from `pipx list` to their environments.
|
|
135
|
+
- **uv**: maps exposed executables from `uv tool list` to installed tools.
|
|
136
|
+
- **Cargo**: maps binaries from `cargo install --list` to installed crates.
|
|
137
|
+
- **Homebrew**: queries installed formulae and matches files listed by Homebrew.
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
`pkgwho` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
pkgwho/__about__.py,sha256=C3lXAQDT9nnQBBFS60poxgmFjhywLP_kULuM6BLaFTY,134
|
|
2
|
+
pkgwho/__init__.py,sha256=tppZQyuZohMsdaWtpBs6sb6Vp5ygUW_Qi4oF3xG_1vs,112
|
|
3
|
+
pkgwho/cli.py,sha256=lkW62mTO9l8jNsYLhC6s2VpKX76mNXR8ZHPcYTGtZ9U,27362
|
|
4
|
+
pkgwho/shell_config.py,sha256=YCv2GByhpO1tHFIvdlBRRCnCd6ZL_c5lrz9ci730XR8,7141
|
|
5
|
+
pkgwho-0.1.1.dist-info/METADATA,sha256=Usmf7rku-dfdFAaTnRRMPNaFGCH90hOf6QOGk4JMXtk,4977
|
|
6
|
+
pkgwho-0.1.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
7
|
+
pkgwho-0.1.1.dist-info/entry_points.txt,sha256=KGqb0_xsAmlqZtlwvuDRZBivmZg_lDczC8ttL2L6NgA,43
|
|
8
|
+
pkgwho-0.1.1.dist-info/licenses/LICENSE.txt,sha256=9EoQyGLRu9D0o_LNkqCkRT2c-5LDEpBllUzwQ53PbTg,1104
|
|
9
|
+
pkgwho-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026-present Toshihiro Kamiya <kamiya@mbj.nifty.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|