whichowns 0.0.1__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kyle O'Malley
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: whichowns
3
+ Version: 0.0.1
4
+ Summary: Find which package manager owns a command.
5
+ Author: Kyle O'Malley
6
+ Author-email: Kyle O'Malley <j.kyle.omalley@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.11
10
+ Project-URL: Homepage, https://github.com/jkomalley/whichowns
11
+ Project-URL: Repository, https://github.com/jkomalley/whichowns
12
+ Project-URL: Issues, https://github.com/jkomalley/whichowns/issues
13
+ Description-Content-Type: text/markdown
14
+
15
+ # whichowns
16
+
17
+ Find which package manager owns a command.
18
+
19
+ ## Install
20
+
21
+ ```sh
22
+ uv tool install whichowns
23
+ ```
@@ -0,0 +1,9 @@
1
+ # whichowns
2
+
3
+ Find which package manager owns a command.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ uv tool install whichowns
9
+ ```
@@ -0,0 +1,72 @@
1
+ [project]
2
+ name = "whichowns"
3
+ version = "0.0.1"
4
+ description = "Find which package manager owns a command."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ dependencies = []
10
+
11
+ [[project.authors]]
12
+ name = "Kyle O'Malley"
13
+ email = "j.kyle.omalley@gmail.com"
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/jkomalley/whichowns"
17
+ Repository = "https://github.com/jkomalley/whichowns"
18
+ Issues = "https://github.com/jkomalley/whichowns/issues"
19
+
20
+ [project.scripts]
21
+ whichowns = "whichowns.cli:main"
22
+
23
+ [build-system]
24
+ requires = ["uv_build>=0.13.6,<0.14.0"]
25
+ build-backend = "uv_build"
26
+
27
+ [dependency-groups]
28
+ dev = [
29
+ "pre-commit>=4.7.0",
30
+ "pytest>=9.2.0",
31
+ "pytest-cov>=7.1.0",
32
+ "ruff>=0.17.3",
33
+ "ty>=0.0.96",
34
+ ]
35
+
36
+ [tool.ruff.lint]
37
+ select = ["ALL"]
38
+ ignore = [
39
+ "D203",
40
+ "D213",
41
+ "COM812",
42
+ "ISC001",
43
+ "FIX002",
44
+ "TD002",
45
+ "TD003",
46
+ "TRY003",
47
+ "CPY001",
48
+ ]
49
+
50
+ [tool.ruff.lint.per-file-ignores]
51
+ "tests/**" = ["S101"]
52
+
53
+ [tool.ty.environment]
54
+ python-platform = "all"
55
+
56
+ [tool.pytest.ini_options]
57
+ testpaths = ["tests"]
58
+ strict = true
59
+ markers = ["integration: end-to-end tests with package managers; deselected by default"]
60
+ addopts = [
61
+ "-ra",
62
+ "-m",
63
+ "not integration",
64
+ ]
65
+
66
+ [tool.coverage.run]
67
+ source = ["whichowns"]
68
+ branch = true
69
+
70
+ [tool.coverage.report]
71
+ show_missing = true
72
+ exclude_also = ["if __name__ == .__main__."]
@@ -0,0 +1,78 @@
1
+ [project]
2
+ name = "whichowns"
3
+ version = "0.0.1"
4
+ description = "Find which package manager owns a command."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Kyle O'Malley", email = "j.kyle.omalley@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.11"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ dependencies = []
13
+
14
+ [project.urls]
15
+ Homepage = "https://github.com/jkomalley/whichowns"
16
+ Repository = "https://github.com/jkomalley/whichowns"
17
+ Issues = "https://github.com/jkomalley/whichowns/issues"
18
+
19
+ [project.scripts]
20
+ whichowns = "whichowns.cli:main"
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.13.6,<0.14.0"]
24
+ build-backend = "uv_build"
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "pre-commit>=4.7.0",
29
+ "pytest>=9.2.0",
30
+ "pytest-cov>=7.1.0",
31
+ "ruff>=0.17.3",
32
+ "ty>=0.0.96",
33
+ ]
34
+
35
+ [tool.ruff.lint]
36
+ select = ["ALL"]
37
+ ignore = [
38
+ "D203", # one-blank-line-before-class (conflicts with D211)
39
+ "D213", # multi-line-summary-second-line (conflicts with D212)
40
+ "COM812", # missing trailing comma (conflicts with formatter)
41
+ "ISC001", # single-line implicit string concatenation (conflicts with formatter)
42
+ "FIX002", # line contains TODO
43
+ "TD002", # missing author in TODO
44
+ "TD003", # missing issue link in TODO
45
+ "TRY003", # avoid specifying long messages outside the exception class
46
+ "CPY001", # missing copyright notice
47
+ ]
48
+
49
+ [tool.ruff.lint.per-file-ignores]
50
+ "tests/**" = [
51
+ "S101", # use of assert (pytest relies on it)
52
+ ]
53
+
54
+ # ty skips code it considers unreachable on the host platform, so checking on
55
+ # Linux alone would silently skip macOS-only branches.
56
+ [tool.ty.environment]
57
+ python-platform = "all"
58
+
59
+ [tool.pytest.ini_options]
60
+ testpaths = ["tests"]
61
+ strict = true
62
+ markers = [
63
+ "integration: end-to-end tests with package managers; deselected by default",
64
+ ]
65
+ addopts = [
66
+ "-ra",
67
+ "-m", "not integration",
68
+ ]
69
+
70
+ [tool.coverage.run]
71
+ source = ["whichowns"]
72
+ branch = true
73
+
74
+ [tool.coverage.report]
75
+ show_missing = true
76
+ exclude_also = [
77
+ "if __name__ == .__main__.",
78
+ ]
@@ -0,0 +1 @@
1
+ """Find which package manager owns a command."""
@@ -0,0 +1,8 @@
1
+ """Run whichowns with `python -m whichowns`."""
2
+
3
+ import sys
4
+
5
+ from whichowns.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,43 @@
1
+ """Command-line interface: arguments, output and exit codes."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from whichowns.locate import locate
7
+ from whichowns.managers import find_owner
8
+
9
+ PROG = "whichowns"
10
+
11
+
12
+ def main(argv: list[str] | None = None) -> int:
13
+ """Report which package manager owns a command.
14
+
15
+ Args:
16
+ argv: Arguments without the program name, or None to use sys.argv.
17
+
18
+ Returns:
19
+ 0 if the command was found, 1 if not. Usage errors and --help exit
20
+ directly through argparse.
21
+
22
+ """
23
+ # Without an explicit prog, `python -m whichowns` shows "__main__.py".
24
+ parser = argparse.ArgumentParser(
25
+ prog=PROG, description="Find which package manager owns a command."
26
+ )
27
+ parser.add_argument("command", help="command name, or path to an executable")
28
+ parser.add_argument("-v", action="store_true", help="also show the resolved path")
29
+ args = parser.parse_args(argv)
30
+
31
+ location = locate(args.command)
32
+ if location is None:
33
+ sys.stderr.write(f"{PROG}: {args.command}: command not found\n")
34
+ return 1
35
+
36
+ owner = find_owner(location)
37
+ if owner is None:
38
+ sys.stdout.write(f"{args.command}: unmanaged\n")
39
+ else:
40
+ sys.stdout.write(f"{args.command}: {owner.package} ({owner.manager})\n")
41
+ if args.v:
42
+ sys.stdout.write(f"{location.resolved}\n")
43
+ return 0
@@ -0,0 +1,29 @@
1
+ """Find a command on PATH and resolve its symlinks."""
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ from whichowns.models import Location
7
+
8
+
9
+ def locate(command: str) -> Location | None:
10
+ """Find a command by name on PATH, or check a path directly.
11
+
12
+ Args:
13
+ command: A command name, or a path to an executable (anything containing "/").
14
+
15
+ Returns:
16
+ The command's location, or None if it isn't found or isn't executable.
17
+
18
+ """
19
+ match = shutil.which(command)
20
+ if match is None:
21
+ return None
22
+ # which() can return relative paths, "..", and symlinked directories, but
23
+ # detectors compare against canonical paths, such as those in dpkg's file
24
+ # lists. Only the directory is resolved: the command itself may be a symlink
25
+ # that a package owns. Removing ".." as text would be wrong after a
26
+ # symlinked directory.
27
+ found = Path(match)
28
+ path = found.parent.resolve() / found.name
29
+ return Location(command=command, path=path, resolved=path.resolve())
@@ -0,0 +1,27 @@
1
+ """Detector order and owner lookup."""
2
+
3
+ from collections.abc import Iterable
4
+
5
+ from whichowns.models import Detector, Location, Owner
6
+
7
+ DETECTORS: tuple[Detector, ...] = ()
8
+
9
+
10
+ def find_owner(
11
+ location: Location, detectors: Iterable[Detector] = DETECTORS
12
+ ) -> Owner | None:
13
+ """Ask each detector in turn which package manager owns a command.
14
+
15
+ Args:
16
+ location: Where the command was found.
17
+ detectors: Detectors to try, in order.
18
+
19
+ Returns:
20
+ The first owner a detector reports, or None if none match.
21
+
22
+ """
23
+ for detect in detectors:
24
+ owner = detect(location)
25
+ if owner is not None:
26
+ return owner
27
+ return None
@@ -0,0 +1,35 @@
1
+ """Data types shared by command lookup and the package manager detectors."""
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import TypeAlias
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Location:
11
+ """Where a command was found.
12
+
13
+ Attributes:
14
+ command: The argument exactly as given.
15
+ path: The PATH match, or the given path, in its real directory.
16
+ Not followed if it's a symlink itself.
17
+ resolved: The final target after following every symlink.
18
+
19
+ """
20
+
21
+ command: str
22
+ path: Path
23
+ resolved: Path
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Owner:
28
+ """The package manager and package that own a command."""
29
+
30
+ manager: str
31
+ package: str
32
+
33
+
34
+ # The `type` statement needs Python 3.12, and whichowns supports 3.11.
35
+ Detector: TypeAlias = Callable[[Location], Owner | None]