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 ADDED
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: 2026-present Toshihiro Kamiya <kamiya@mbj.nifty.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ __version__ = "0.1.1"
pkgwho/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026-present Toshihiro Kamiya <kamiya@mbj.nifty.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pkgwho = pkgwho.cli:main
@@ -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.