psinfo 0.1.0__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.
psinfo-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dan Blore
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.
psinfo-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: psinfo
3
+ Version: 0.1.0
4
+ Summary: Rich terminal process viewer
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/dblore/psinfo
7
+ Project-URL: Repository, https://github.com/dblore/psinfo
8
+ Project-URL: Issues, https://github.com/dblore/psinfo/issues
9
+ Keywords: process,ps,terminal,cli,rich
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Operating System :: Microsoft :: Windows
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Topic :: System :: Monitoring
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.9
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: psutil>=5.9
29
+ Requires-Dist: rich>=13.0
30
+ Dynamic: license-file
31
+
32
+ # psinfo
33
+
34
+ A rich terminal process viewer. Search, filter, and watch running processes with colour-coded output.
35
+
36
+ psinfo is for when you have a specific question — "what's on port 8080", "how much memory is Chrome using across all its helpers", "is nginx running and what's it doing" — and want a clean answer without launching a full process manager or constructing a `ps aux | grep | awk` pipeline.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install -e .
42
+ # or, to install as an isolated CLI tool:
43
+ pipx install .
44
+ ```
45
+
46
+ `pipx` is recommended for CLI tools — it installs into an isolated environment and puts `psinfo` on your PATH. Install it with `brew install pipx` or `pip install pipx`.
47
+
48
+ ## Usage
49
+
50
+ ```
51
+ psinfo <name> search by process name or command
52
+ psinfo -u <username> all processes owned by a user
53
+ psinfo -p <port> process listening on a port
54
+ psinfo -P <pid> single process by PID
55
+ ```
56
+
57
+ ### Flags
58
+
59
+ | Flag | Short | Description |
60
+ |---|---|---|
61
+ | `--watch [N]` | `-w` | Live refresh every N seconds (default: 2) |
62
+ | `--sort cpu\|mem` | `-s` | Sort order (default: cpu) |
63
+ | `--tree` | `-t` | Group results by process tree hierarchy |
64
+
65
+ Flags compose freely:
66
+
67
+ ```bash
68
+ psinfo nginx -w
69
+ psinfo python -w 5 -s mem
70
+ psinfo slack -t -w
71
+ ```
72
+
73
+ ## Output
74
+
75
+ ### Card view (default)
76
+
77
+ Each matching process is shown as a panel. The highest-CPU process gets a green border. Metrics are colour-coded: green (< 10%), orange (≥ 10%), red (≥ 50%).
78
+
79
+ ![Card view](https://raw.githubusercontent.com/dblore/psinfo/main/assets/card-view.svg)
80
+
81
+ ### Tree view (`-t`)
82
+
83
+ Results are grouped by parent/child relationships. Parent nodes show their own CPU plus a `∑` total across the whole subtree.
84
+
85
+ ![Tree view](https://raw.githubusercontent.com/dblore/psinfo/main/assets/tree-view.svg)
86
+
87
+ ## Requirements
88
+
89
+ - Python 3.9+
90
+ - psutil >= 5.9
91
+ - rich >= 13.0
92
+
93
+ ## Platform support
94
+
95
+ | Platform | Status | Notes |
96
+ |---|---|---|
97
+ | macOS | ✓ | `--port` requires `sudo` |
98
+ | Linux | ✓ | |
99
+ | Windows | ✓ | Shows handles instead of file descriptors |
100
+
101
+ ## License
102
+
103
+ MIT — see [LICENSE](LICENSE).
psinfo-0.1.0/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # psinfo
2
+
3
+ A rich terminal process viewer. Search, filter, and watch running processes with colour-coded output.
4
+
5
+ psinfo is for when you have a specific question — "what's on port 8080", "how much memory is Chrome using across all its helpers", "is nginx running and what's it doing" — and want a clean answer without launching a full process manager or constructing a `ps aux | grep | awk` pipeline.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install -e .
11
+ # or, to install as an isolated CLI tool:
12
+ pipx install .
13
+ ```
14
+
15
+ `pipx` is recommended for CLI tools — it installs into an isolated environment and puts `psinfo` on your PATH. Install it with `brew install pipx` or `pip install pipx`.
16
+
17
+ ## Usage
18
+
19
+ ```
20
+ psinfo <name> search by process name or command
21
+ psinfo -u <username> all processes owned by a user
22
+ psinfo -p <port> process listening on a port
23
+ psinfo -P <pid> single process by PID
24
+ ```
25
+
26
+ ### Flags
27
+
28
+ | Flag | Short | Description |
29
+ |---|---|---|
30
+ | `--watch [N]` | `-w` | Live refresh every N seconds (default: 2) |
31
+ | `--sort cpu\|mem` | `-s` | Sort order (default: cpu) |
32
+ | `--tree` | `-t` | Group results by process tree hierarchy |
33
+
34
+ Flags compose freely:
35
+
36
+ ```bash
37
+ psinfo nginx -w
38
+ psinfo python -w 5 -s mem
39
+ psinfo slack -t -w
40
+ ```
41
+
42
+ ## Output
43
+
44
+ ### Card view (default)
45
+
46
+ Each matching process is shown as a panel. The highest-CPU process gets a green border. Metrics are colour-coded: green (< 10%), orange (≥ 10%), red (≥ 50%).
47
+
48
+ ![Card view](https://raw.githubusercontent.com/dblore/psinfo/main/assets/card-view.svg)
49
+
50
+ ### Tree view (`-t`)
51
+
52
+ Results are grouped by parent/child relationships. Parent nodes show their own CPU plus a `∑` total across the whole subtree.
53
+
54
+ ![Tree view](https://raw.githubusercontent.com/dblore/psinfo/main/assets/tree-view.svg)
55
+
56
+ ## Requirements
57
+
58
+ - Python 3.9+
59
+ - psutil >= 5.9
60
+ - rich >= 13.0
61
+
62
+ ## Platform support
63
+
64
+ | Platform | Status | Notes |
65
+ |---|---|---|
66
+ | macOS | ✓ | `--port` requires `sudo` |
67
+ | Linux | ✓ | |
68
+ | Windows | ✓ | Shows handles instead of file descriptors |
69
+
70
+ ## License
71
+
72
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: psinfo
3
+ Version: 0.1.0
4
+ Summary: Rich terminal process viewer
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/dblore/psinfo
7
+ Project-URL: Repository, https://github.com/dblore/psinfo
8
+ Project-URL: Issues, https://github.com/dblore/psinfo/issues
9
+ Keywords: process,ps,terminal,cli,rich
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Operating System :: Microsoft :: Windows
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Topic :: System :: Monitoring
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.9
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: psutil>=5.9
29
+ Requires-Dist: rich>=13.0
30
+ Dynamic: license-file
31
+
32
+ # psinfo
33
+
34
+ A rich terminal process viewer. Search, filter, and watch running processes with colour-coded output.
35
+
36
+ psinfo is for when you have a specific question — "what's on port 8080", "how much memory is Chrome using across all its helpers", "is nginx running and what's it doing" — and want a clean answer without launching a full process manager or constructing a `ps aux | grep | awk` pipeline.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install -e .
42
+ # or, to install as an isolated CLI tool:
43
+ pipx install .
44
+ ```
45
+
46
+ `pipx` is recommended for CLI tools — it installs into an isolated environment and puts `psinfo` on your PATH. Install it with `brew install pipx` or `pip install pipx`.
47
+
48
+ ## Usage
49
+
50
+ ```
51
+ psinfo <name> search by process name or command
52
+ psinfo -u <username> all processes owned by a user
53
+ psinfo -p <port> process listening on a port
54
+ psinfo -P <pid> single process by PID
55
+ ```
56
+
57
+ ### Flags
58
+
59
+ | Flag | Short | Description |
60
+ |---|---|---|
61
+ | `--watch [N]` | `-w` | Live refresh every N seconds (default: 2) |
62
+ | `--sort cpu\|mem` | `-s` | Sort order (default: cpu) |
63
+ | `--tree` | `-t` | Group results by process tree hierarchy |
64
+
65
+ Flags compose freely:
66
+
67
+ ```bash
68
+ psinfo nginx -w
69
+ psinfo python -w 5 -s mem
70
+ psinfo slack -t -w
71
+ ```
72
+
73
+ ## Output
74
+
75
+ ### Card view (default)
76
+
77
+ Each matching process is shown as a panel. The highest-CPU process gets a green border. Metrics are colour-coded: green (< 10%), orange (≥ 10%), red (≥ 50%).
78
+
79
+ ![Card view](https://raw.githubusercontent.com/dblore/psinfo/main/assets/card-view.svg)
80
+
81
+ ### Tree view (`-t`)
82
+
83
+ Results are grouped by parent/child relationships. Parent nodes show their own CPU plus a `∑` total across the whole subtree.
84
+
85
+ ![Tree view](https://raw.githubusercontent.com/dblore/psinfo/main/assets/tree-view.svg)
86
+
87
+ ## Requirements
88
+
89
+ - Python 3.9+
90
+ - psutil >= 5.9
91
+ - rich >= 13.0
92
+
93
+ ## Platform support
94
+
95
+ | Platform | Status | Notes |
96
+ |---|---|---|
97
+ | macOS | ✓ | `--port` requires `sudo` |
98
+ | Linux | ✓ | |
99
+ | Windows | ✓ | Shows handles instead of file descriptors |
100
+
101
+ ## License
102
+
103
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ psinfo.egg-info/PKG-INFO
5
+ psinfo.egg-info/SOURCES.txt
6
+ psinfo.egg-info/dependency_links.txt
7
+ psinfo.egg-info/entry_points.txt
8
+ psinfo.egg-info/requires.txt
9
+ psinfo.egg-info/top_level.txt
10
+ tests/test_cli.py
11
+ tests/test_query.py
12
+ tests/test_render.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ psinfo = psinfo:main
@@ -0,0 +1,2 @@
1
+ psutil>=5.9
2
+ rich>=13.0
@@ -0,0 +1 @@
1
+ assets
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "psinfo"
7
+ version = "0.1.0"
8
+ description = "Rich terminal process viewer"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ keywords = ["process", "ps", "terminal", "cli", "rich"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: System Administrators",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: MacOS",
20
+ "Operating System :: POSIX :: Linux",
21
+ "Operating System :: Microsoft :: Windows",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.9",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Topic :: System :: Monitoring",
28
+ "Topic :: Utilities",
29
+ ]
30
+ dependencies = [
31
+ "psutil>=5.9",
32
+ "rich>=13.0",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/dblore/psinfo"
37
+ Repository = "https://github.com/dblore/psinfo"
38
+ Issues = "https://github.com/dblore/psinfo/issues"
39
+
40
+ [project.scripts]
41
+ psinfo = "psinfo:main"
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
psinfo-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,99 @@
1
+ import sys
2
+ import pytest
3
+ from unittest.mock import patch, MagicMock
4
+ from psinfo import ProcessInfo
5
+
6
+
7
+ def test_parser_accepts_name_positional():
8
+ from psinfo import build_parser
9
+ args = build_parser().parse_args(["mysql"])
10
+ assert args.name == "mysql"
11
+
12
+
13
+ def test_parser_accepts_user_flag():
14
+ from psinfo import build_parser
15
+ args = build_parser().parse_args(["--user", "root"])
16
+ assert args.user == "root"
17
+
18
+
19
+ def test_parser_accepts_port_flag():
20
+ from psinfo import build_parser
21
+ args = build_parser().parse_args(["--port", "3306"])
22
+ assert args.port == 3306
23
+
24
+
25
+ def test_parser_accepts_pid_flag():
26
+ from psinfo import build_parser
27
+ args = build_parser().parse_args(["--pid", "1234"])
28
+ assert args.pid == 1234
29
+
30
+
31
+ def test_parser_watch_defaults_to_2():
32
+ from psinfo import build_parser
33
+ args = build_parser().parse_args(["mysql", "--watch"])
34
+ assert args.watch == 2.0
35
+
36
+
37
+ def test_parser_watch_accepts_custom_interval():
38
+ from psinfo import build_parser
39
+ args = build_parser().parse_args(["mysql", "--watch", "5"])
40
+ assert args.watch == 5.0
41
+
42
+
43
+ def test_parser_sort_defaults_to_cpu():
44
+ from psinfo import build_parser
45
+ args = build_parser().parse_args(["mysql"])
46
+ assert args.sort == "cpu"
47
+
48
+
49
+ def test_parser_sort_accepts_mem():
50
+ from psinfo import build_parser
51
+ args = build_parser().parse_args(["mysql", "--sort", "mem"])
52
+ assert args.sort == "mem"
53
+
54
+
55
+ def test_main_calls_find_by_name(mocker):
56
+ from psinfo import main
57
+ mock_find = mocker.patch("psinfo.find_by_name", return_value=[])
58
+ mocker.patch("psutil.net_connections", return_value=[])
59
+ with patch("sys.argv", ["psinfo", "mysql"]):
60
+ main()
61
+ mock_find.assert_called_once_with("mysql")
62
+
63
+
64
+ def test_main_calls_find_by_user(mocker):
65
+ from psinfo import main
66
+ mock_find = mocker.patch("psinfo.find_by_user", return_value=[])
67
+ with patch("sys.argv", ["psinfo", "--user", "root"]):
68
+ main()
69
+ mock_find.assert_called_once_with("root")
70
+
71
+
72
+ def test_main_calls_find_by_pid(mocker):
73
+ from psinfo import main
74
+ proc = ProcessInfo(
75
+ pid=1234, name="test", cmdline=[], username="user",
76
+ cpu_percent=0.0, mem_percent=0.0, rss_mb=0.0,
77
+ num_threads=1, create_time=0.0, num_fds=0
78
+ )
79
+ mock_find = mocker.patch("psinfo.find_by_pid", return_value=proc)
80
+ with patch("sys.argv", ["psinfo", "--pid", "1234"]):
81
+ main()
82
+ mock_find.assert_called_once_with(1234)
83
+
84
+
85
+ def test_main_calls_find_by_port(mocker):
86
+ from psinfo import main
87
+ mock_find = mocker.patch("psinfo.find_by_port", return_value=None)
88
+ with patch("sys.argv", ["psinfo", "--port", "3306"]):
89
+ main()
90
+ mock_find.assert_called_once_with(3306)
91
+
92
+
93
+ def test_main_exits_1_when_pid_not_found(mocker):
94
+ from psinfo import main
95
+ mocker.patch("psinfo.find_by_pid", return_value=None)
96
+ with patch("sys.argv", ["psinfo", "--pid", "9999"]):
97
+ with pytest.raises(SystemExit) as exc:
98
+ main()
99
+ assert exc.value.code == 1
@@ -0,0 +1,261 @@
1
+ from unittest.mock import MagicMock
2
+
3
+
4
+ def make_mock_proc(
5
+ pid=1234,
6
+ name="mysqld",
7
+ cmdline=None,
8
+ username="mysql",
9
+ cpu_percent=0.5,
10
+ memory_percent=2.1,
11
+ rss=176160768, # bytes → 168 MB
12
+ num_threads=32,
13
+ create_time=1000000.0,
14
+ num_fds=48,
15
+ ):
16
+ if cmdline is None:
17
+ cmdline = ["/usr/sbin/mysqld", "--datadir=/var/lib/mysql"]
18
+ proc = MagicMock()
19
+ mem_info = MagicMock()
20
+ mem_info.rss = rss
21
+ proc.info = {
22
+ "pid": pid,
23
+ "name": name,
24
+ "cmdline": cmdline,
25
+ "username": username,
26
+ "cpu_percent": cpu_percent,
27
+ "memory_percent": memory_percent,
28
+ "memory_info": mem_info,
29
+ "num_threads": num_threads,
30
+ "create_time": create_time,
31
+ "num_fds": num_fds,
32
+ }
33
+ proc.as_dict.return_value = dict(proc.info)
34
+ return proc
35
+
36
+
37
+ def test_process_info_fields():
38
+ from psinfo import ProcessInfo
39
+ p = ProcessInfo(
40
+ pid=1234,
41
+ name="mysqld",
42
+ cmdline=["/usr/sbin/mysqld", "--datadir=/var/lib/mysql"],
43
+ username="mysql",
44
+ cpu_percent=0.5,
45
+ mem_percent=2.1,
46
+ rss_mb=168.0,
47
+ num_threads=32,
48
+ create_time=1000000.0,
49
+ num_fds=48,
50
+ )
51
+ assert p.pid == 1234
52
+ assert p.name == "mysqld"
53
+ assert p.rss_mb == 168.0
54
+
55
+
56
+ def test_command_joins_cmdline():
57
+ from psinfo import ProcessInfo
58
+ p = ProcessInfo(
59
+ pid=1, name="mysqld",
60
+ cmdline=["/usr/sbin/mysqld", "--datadir=/var/lib/mysql"],
61
+ username="mysql", cpu_percent=0.0, mem_percent=0.0,
62
+ rss_mb=0.0, num_threads=1, create_time=1000000.0, num_fds=0,
63
+ )
64
+ assert p.command == "/usr/sbin/mysqld --datadir=/var/lib/mysql"
65
+
66
+
67
+ def test_command_falls_back_to_name_when_no_cmdline():
68
+ from psinfo import ProcessInfo
69
+ p = ProcessInfo(
70
+ pid=1, name="kernel_task", cmdline=[],
71
+ username="root", cpu_percent=0.0, mem_percent=0.0,
72
+ rss_mb=0.0, num_threads=1, create_time=1000000.0, num_fds=0,
73
+ )
74
+ assert p.command == "kernel_task"
75
+
76
+
77
+ def test_proc_to_info_converts_mock():
78
+ from psinfo import _proc_to_info
79
+ mock = make_mock_proc()
80
+ result = _proc_to_info(mock)
81
+ assert result is not None
82
+ assert result.pid == 1234
83
+ assert result.name == "mysqld"
84
+ assert abs(result.rss_mb - 168.0) < 1.0
85
+ assert result.num_threads == 32
86
+
87
+
88
+ def test_proc_to_info_returns_none_on_no_such_process():
89
+ import psutil
90
+ from psinfo import _proc_to_info
91
+ mock = MagicMock()
92
+ mock.as_dict.side_effect = psutil.NoSuchProcess(pid=9999)
93
+ assert _proc_to_info(mock) is None
94
+
95
+
96
+ def test_proc_to_info_returns_none_on_access_denied():
97
+ import psutil
98
+ from psinfo import _proc_to_info
99
+ mock = MagicMock()
100
+ mock.as_dict.side_effect = psutil.AccessDenied(pid=1)
101
+ assert _proc_to_info(mock) is None
102
+
103
+
104
+ def test_find_by_name_matches_name_field(mocker):
105
+ from psinfo import find_by_name
106
+ mock_mysql = make_mock_proc(name="mysqld", cmdline=["/usr/sbin/mysqld"])
107
+ mock_other = make_mock_proc(pid=9999, name="nginx", cmdline=["/usr/sbin/nginx"])
108
+ mocker.patch("psutil.process_iter", return_value=[mock_mysql, mock_other])
109
+ results = find_by_name("mysql")
110
+ assert len(results) == 1
111
+ assert results[0].name == "mysqld"
112
+
113
+
114
+ def test_find_by_name_matches_cmdline(mocker):
115
+ from psinfo import find_by_name
116
+ mock = make_mock_proc(name="python3", cmdline=["python3", "manage.py", "runserver"])
117
+ mocker.patch("psutil.process_iter", return_value=[mock])
118
+ results = find_by_name("manage")
119
+ assert len(results) == 1
120
+
121
+
122
+ def test_find_by_name_is_case_insensitive(mocker):
123
+ from psinfo import find_by_name
124
+ mock = make_mock_proc(name="MySQLd")
125
+ mocker.patch("psutil.process_iter", return_value=[mock])
126
+ results = find_by_name("mysql")
127
+ assert len(results) == 1
128
+
129
+
130
+ def test_find_by_name_returns_empty_when_no_match(mocker):
131
+ from psinfo import find_by_name
132
+ mock = make_mock_proc(name="nginx", cmdline=["/usr/sbin/nginx"])
133
+ mocker.patch("psutil.process_iter", return_value=[mock])
134
+ results = find_by_name("mysql")
135
+ assert results == []
136
+
137
+
138
+ def test_find_by_name_skips_inaccessible_processes(mocker):
139
+ import psutil
140
+ from psinfo import find_by_name
141
+ bad_proc = MagicMock()
142
+ bad_proc.info = MagicMock()
143
+ bad_proc.info.get = MagicMock(side_effect=psutil.AccessDenied(pid=1))
144
+ good = make_mock_proc(name="mysqld")
145
+ mocker.patch("psutil.process_iter", return_value=[bad_proc, good])
146
+ results = find_by_name("mysql")
147
+ assert len(results) == 1
148
+
149
+
150
+ def test_find_by_user_filters_by_username(mocker):
151
+ from psinfo import find_by_user
152
+ mysql_proc = make_mock_proc(pid=1234, name="mysqld", username="mysql")
153
+ root_proc = make_mock_proc(pid=1235, name="sshd", username="root")
154
+ mocker.patch("psutil.process_iter", return_value=[mysql_proc, root_proc])
155
+ results = find_by_user("mysql")
156
+ assert len(results) == 1
157
+ assert results[0].username == "mysql"
158
+
159
+
160
+ def test_find_by_user_is_case_insensitive(mocker):
161
+ from psinfo import find_by_user
162
+ proc = make_mock_proc(username="MySQL")
163
+ mocker.patch("psutil.process_iter", return_value=[proc])
164
+ results = find_by_user("mysql")
165
+ assert len(results) == 1
166
+
167
+
168
+ def test_find_by_pid_returns_process(mocker):
169
+ from psinfo import find_by_pid
170
+ mock = make_mock_proc(pid=1234)
171
+ mocker.patch("psutil.Process", return_value=mock)
172
+ result = find_by_pid(1234)
173
+ assert result is not None
174
+ assert result.pid == 1234
175
+
176
+
177
+ def test_find_by_pid_returns_none_when_not_found(mocker):
178
+ import psutil
179
+ from psinfo import find_by_pid
180
+ mocker.patch("psutil.Process", side_effect=psutil.NoSuchProcess(pid=9999))
181
+ assert find_by_pid(9999) is None
182
+
183
+
184
+ def test_find_by_port_returns_process_owning_port(mocker):
185
+ from psinfo import find_by_port
186
+ conn = MagicMock()
187
+ conn.laddr.port = 3306
188
+ conn.pid = 1234
189
+ mocker.patch("psutil.net_connections", return_value=[conn])
190
+ mock_proc = make_mock_proc(pid=1234, name="mysqld")
191
+ mocker.patch("psutil.Process", return_value=mock_proc)
192
+ result = find_by_port(3306)
193
+ assert result is not None
194
+ assert result.pid == 1234
195
+
196
+
197
+ def test_find_by_port_returns_none_when_port_not_in_use(mocker):
198
+ from psinfo import find_by_port
199
+ conn = MagicMock()
200
+ conn.laddr.port = 8080
201
+ conn.pid = 999
202
+ mocker.patch("psutil.net_connections", return_value=[conn])
203
+ result = find_by_port(3306)
204
+ assert result is None
205
+
206
+
207
+ def test_find_by_port_returns_none_on_access_denied(mocker):
208
+ import psutil
209
+ from psinfo import find_by_port
210
+ mocker.patch("psutil.net_connections", side_effect=psutil.AccessDenied(pid=0))
211
+ assert find_by_port(3306) is None
212
+
213
+
214
+ def test_find_by_pid_returns_none_on_access_denied(mocker):
215
+ import psutil
216
+ from psinfo import find_by_pid
217
+ mocker.patch("psutil.Process", side_effect=psutil.AccessDenied(pid=1))
218
+ assert find_by_pid(1) is None
219
+
220
+
221
+ def test_find_by_port_prefers_listen_state(mocker):
222
+ from psinfo import find_by_port
223
+ listen_conn = MagicMock()
224
+ listen_conn.laddr.port = 3306
225
+ listen_conn.pid = 1234
226
+ listen_conn.status = "LISTEN"
227
+ estab_conn = MagicMock()
228
+ estab_conn.laddr.port = 3306
229
+ estab_conn.pid = 5678
230
+ estab_conn.status = "ESTABLISHED"
231
+ mocker.patch("psutil.net_connections", return_value=[estab_conn, listen_conn])
232
+ mock_proc = make_mock_proc(pid=1234, name="mysqld")
233
+ mocker.patch("psutil.Process", return_value=mock_proc)
234
+ result = find_by_port(3306)
235
+ assert result is not None
236
+ assert result.pid == 1234 # LISTEN connection preferred over ESTABLISHED
237
+
238
+
239
+ def test_find_by_name_excludes_self(mocker):
240
+ import os
241
+ from psinfo import find_by_name
242
+ self_proc = make_mock_proc(pid=os.getpid(), name="python3",
243
+ cmdline=["python3", "psinfo", "mysql"])
244
+ other_proc = make_mock_proc(pid=9999, name="mysqld", cmdline=["/usr/sbin/mysqld"])
245
+ mocker.patch("psutil.process_iter", return_value=[self_proc, other_proc])
246
+ results = find_by_name("mysql")
247
+ assert all(r.pid != os.getpid() for r in results)
248
+ assert len(results) == 1
249
+ assert results[0].pid == 9999
250
+
251
+
252
+ def test_find_by_user_excludes_self(mocker):
253
+ import os
254
+ from psinfo import find_by_user
255
+ self_proc = make_mock_proc(pid=os.getpid(), name="python3", username="dan")
256
+ other_proc = make_mock_proc(pid=9999, name="bash", username="dan")
257
+ mocker.patch("psutil.process_iter", return_value=[self_proc, other_proc])
258
+ results = find_by_user("dan")
259
+ assert all(r.pid != os.getpid() for r in results)
260
+ assert len(results) == 1
261
+ assert results[0].pid == 9999
@@ -0,0 +1,177 @@
1
+ import io
2
+ import pytest
3
+ from rich.console import Console
4
+ from rich.panel import Panel
5
+
6
+
7
+ def make_proc(cpu=0.5, mem=2.1, rss_mb=168.0, name="mysqld", pid=1234,
8
+ cmdline=None, username="mysql", num_threads=32, num_fds=48,
9
+ create_time=1000000.0):
10
+ if cmdline is None:
11
+ cmdline = ["/usr/sbin/mysqld", "--datadir=/var/lib/mysql"]
12
+ from psinfo import ProcessInfo
13
+ return ProcessInfo(
14
+ pid=pid, name=name, cmdline=cmdline, username=username,
15
+ cpu_percent=cpu, mem_percent=mem, rss_mb=rss_mb,
16
+ num_threads=num_threads, create_time=create_time, num_fds=num_fds,
17
+ )
18
+
19
+
20
+ def render_to_str(renderable) -> str:
21
+ buf = io.StringIO()
22
+ console = Console(file=buf, width=100, highlight=False, markup=False)
23
+ console.print(renderable)
24
+ return buf.getvalue()
25
+
26
+
27
+ def test_render_card_returns_panel():
28
+ from psinfo import render_card
29
+ proc = make_proc()
30
+ card = render_card(proc, highlight=False)
31
+ assert isinstance(card, Panel)
32
+
33
+
34
+ def test_render_card_contains_pid():
35
+ from psinfo import render_card
36
+ proc = make_proc(pid=1234)
37
+ output = render_to_str(render_card(proc))
38
+ assert "1234" in output
39
+
40
+
41
+ def test_render_card_contains_process_name():
42
+ from psinfo import render_card
43
+ proc = make_proc(name="mysqld")
44
+ output = render_to_str(render_card(proc))
45
+ assert "mysqld" in output
46
+
47
+
48
+ def test_render_card_contains_username():
49
+ from psinfo import render_card
50
+ proc = make_proc(username="mysql")
51
+ output = render_to_str(render_card(proc))
52
+ assert "mysql" in output
53
+
54
+
55
+ def test_render_card_contains_rss():
56
+ from psinfo import render_card
57
+ proc = make_proc(rss_mb=168.0)
58
+ output = render_to_str(render_card(proc))
59
+ assert "168" in output
60
+
61
+
62
+ def test_render_card_contains_thread_count():
63
+ from psinfo import render_card
64
+ proc = make_proc(num_threads=32)
65
+ output = render_to_str(render_card(proc))
66
+ assert "32" in output
67
+
68
+
69
+ def test_metric_color_green_below_10():
70
+ from psinfo import _metric_color
71
+ assert _metric_color(5.0) == "green"
72
+
73
+
74
+ def test_metric_color_orange_between_10_and_50():
75
+ from psinfo import _metric_color
76
+ assert _metric_color(25.0) == "dark_orange"
77
+
78
+
79
+ def test_metric_color_red_above_50():
80
+ from psinfo import _metric_color
81
+ assert _metric_color(75.0) == "red"
82
+
83
+
84
+ def test_render_card_contains_cpu():
85
+ from psinfo import render_card
86
+ proc = make_proc(cpu=42.5)
87
+ output = render_to_str(render_card(proc))
88
+ assert "42.5" in output
89
+
90
+
91
+ def test_render_card_contains_mem():
92
+ from psinfo import render_card
93
+ proc = make_proc(mem=3.7)
94
+ output = render_to_str(render_card(proc))
95
+ assert "3.7" in output
96
+
97
+
98
+ def test_render_card_contains_fd_count():
99
+ from psinfo import render_card
100
+ proc = make_proc(num_fds=99)
101
+ output = render_to_str(render_card(proc))
102
+ assert "99" in output
103
+
104
+
105
+ def test_render_card_contains_command():
106
+ from psinfo import render_card
107
+ proc = make_proc(cmdline=["/usr/sbin/mysqld", "--datadir=/var/lib/mysql"])
108
+ output = render_to_str(render_card(proc))
109
+ assert "/usr/sbin/mysqld" in output
110
+
111
+
112
+ def test_render_card_highlight_uses_green_title():
113
+ from psinfo import render_card
114
+ from rich.panel import Panel
115
+ from rich.text import Text
116
+ proc = make_proc(name="mysqld")
117
+ highlighted_card = render_card(proc, highlight=True)
118
+ normal_card = render_card(proc, highlight=False)
119
+ # Highlighted card has green border, normal has dim
120
+ assert highlighted_card.border_style == "green"
121
+ assert normal_card.border_style == "dim"
122
+
123
+
124
+ def test_metric_color_at_warn_boundary():
125
+ from psinfo import _metric_color
126
+ assert _metric_color(10.0) == "dark_orange" # exactly at warn threshold
127
+
128
+
129
+ def test_metric_color_at_critical_boundary():
130
+ from psinfo import _metric_color
131
+ assert _metric_color(50.0) == "red" # exactly at critical threshold
132
+
133
+
134
+ def test_render_results_shows_match_count():
135
+ from psinfo import render_results
136
+ procs = [make_proc(pid=1), make_proc(pid=2, name="mysqld_safe")]
137
+ output = render_to_str(render_results(procs, "mysql"))
138
+ assert "2" in output
139
+ assert "mysql" in output
140
+
141
+
142
+ def test_render_results_shows_no_processes_when_empty():
143
+ from psinfo import render_results
144
+ output = render_to_str(render_results([], "mysql"))
145
+ assert "No processes found" in output
146
+
147
+
148
+ def test_sort_procs_by_cpu_descending():
149
+ from psinfo import sort_procs
150
+ procs = [make_proc(cpu=1.0, pid=1), make_proc(cpu=5.0, pid=2), make_proc(cpu=0.5, pid=3)]
151
+ result = sort_procs(procs, "cpu")
152
+ assert result[0].cpu_percent == 5.0
153
+ assert result[-1].cpu_percent == 0.5
154
+
155
+
156
+ def test_sort_procs_by_mem_descending():
157
+ from psinfo import sort_procs
158
+ procs = [make_proc(mem=1.0, pid=1), make_proc(mem=5.0, pid=2)]
159
+ result = sort_procs(procs, "mem")
160
+ assert result[0].mem_percent == 5.0
161
+
162
+
163
+ def test_sort_procs_by_pid_ascending():
164
+ from psinfo import sort_procs
165
+ procs = [make_proc(pid=300), make_proc(pid=100), make_proc(pid=200)]
166
+ result = sort_procs(procs, "pid")
167
+ assert result[0].pid == 100
168
+ assert result[-1].pid == 300
169
+
170
+
171
+ def test_render_results_highlights_highest_cpu():
172
+ from psinfo import render_results
173
+ high = make_proc(pid=1, cpu=50.0, name="high_cpu")
174
+ low = make_proc(pid=2, cpu=1.0, name="low_cpu")
175
+ output = render_to_str(render_results([high, low], "test"))
176
+ assert "high_cpu" in output
177
+ assert "low_cpu" in output