portvac 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.
portvac-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 portvac contributors
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.
portvac-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: portvac
3
+ Version: 0.1.0
4
+ Summary: See what's listening on a TCP port and free it - cross-platform, zero dependencies.
5
+ Author: yyfjj
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jjdoor/portvac-py
8
+ Project-URL: Repository, https://github.com/jjdoor/portvac-py
9
+ Project-URL: Issues, https://github.com/jjdoor/portvac-py/issues
10
+ Keywords: port,kill-port,process,lsof,cli,devtools,cross-platform,debugging,sysadmin,zero-dependencies
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: POSIX
17
+ Classifier: Operating System :: MacOS
18
+ Classifier: Operating System :: Microsoft :: Windows
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Topic :: System :: Networking :: Monitoring
21
+ Classifier: Topic :: Utilities
22
+ Requires-Python: >=3.8
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Dynamic: license-file
26
+
27
+ # portvac
28
+
29
+ **See what's listening on a TCP port - and free it.** The "port already in use"
30
+ error costs every developer a minute of `lsof` / `netstat` / `taskkill`
31
+ archaeology. `portvac` lists who's on a port in one command, then vacates it on
32
+ confirm. Cross-platform, **zero dependencies**.
33
+
34
+ ```bash
35
+ pip install portvac
36
+ portvac # list every listening TCP port
37
+ portvac 3000 # show what's on 3000, then kill it (asks first)
38
+ portvac 3000 -k # kill without asking (scripts / CI)
39
+ portvac 3000 -s KILL # SIGKILL instead of the default SIGTERM
40
+ ```
41
+
42
+ Node shop? `npx portvac 3000 -k` works too - same tool, same behaviour.
43
+
44
+ ## Why
45
+
46
+ > "Port 3000 is already in use" - and now you're pasting `lsof -i :3000` into
47
+ > Stack Overflow for the fourth time this week.
48
+
49
+ `lsof` flags differ from `netstat` which differs from Windows `taskkill`. You
50
+ remember none of them under pressure. `portvac` is the one command that works
51
+ the same on macOS, Linux, and Windows: **look first, kill second**, never
52
+ silent.
53
+
54
+ ## How it works
55
+
56
+ 1. **List** - shells out to the platform's own tool (`lsof` / `ss` / `netstat`)
57
+ to read listening sockets. No daemon, no network.
58
+ 2. **Show** - prints a clean table: port, pid, process name, address, family.
59
+ 3. **Vacate** - on a port argument, asks `y/N`, then sends a signal (default
60
+ `TERM`) to the owning pid(s). `-k` skips the prompt.
61
+
62
+ Nothing is installed system-wide and no data leaves your machine.
63
+
64
+ ## Usage
65
+
66
+ ```
67
+ portvac List every TCP port currently listening
68
+ portvac <port> Show what's on <port>, then kill it (asks first)
69
+ portvac <port> --list Show only, don't kill
70
+ portvac <port> -k Kill without confirmation
71
+ portvac <port> -s KILL Send KILL instead of TERM
72
+ portvac --json [<port>] Machine-readable output (great for piping)
73
+
74
+ -k, --force Kill without confirmation
75
+ -l, --list List only; never kill
76
+ -s, --signal <SIG> TERM (default), KILL, INT, QUIT, HUP
77
+ --json Emit JSON; suppresses colors and prompts
78
+ -v, --version
79
+ ```
80
+
81
+ ### JSON
82
+
83
+ ```bash
84
+ $ portvac --json 3000
85
+ {
86
+ "port": 3000,
87
+ "listeners": [
88
+ { "port": 3000, "pid": 12345, "name": "node", "address": "0.0.0.0", "protocol": "tcp", "family": "ipv4" }
89
+ ]
90
+ }
91
+ ```
92
+
93
+ ## Platform notes
94
+
95
+ | Platform | Lookup | Kill |
96
+ |----------|--------|------|
97
+ | macOS / BSD | `lsof -iTCP -sTCP:LISTEN -P -n` | `os.kill(pid, sig)` |
98
+ | Linux | `ss -tlnp` (falls back to `netstat -tlnp`) | `os.kill(pid, sig)` |
99
+ | Windows | `netstat -ano` + `tasklist` | `taskkill /PID <pid> /F /T` |
100
+
101
+ On Windows there's no POSIX signal hierarchy, so `portvac` force-terminates
102
+ (`taskkill /F`). To see another user's processes you may need elevated
103
+ privileges, same as the underlying tools.
104
+
105
+ ## Exit codes
106
+
107
+ | Code | Meaning |
108
+ |------|---------|
109
+ | `0` | listed, or killed successfully |
110
+ | `1` | nothing listening / port not in use / aborted |
111
+ | `2` | error (bad args, tool missing, kill failed) |
112
+
113
+ ## License
114
+
115
+ MIT
@@ -0,0 +1,89 @@
1
+ # portvac
2
+
3
+ **See what's listening on a TCP port - and free it.** The "port already in use"
4
+ error costs every developer a minute of `lsof` / `netstat` / `taskkill`
5
+ archaeology. `portvac` lists who's on a port in one command, then vacates it on
6
+ confirm. Cross-platform, **zero dependencies**.
7
+
8
+ ```bash
9
+ pip install portvac
10
+ portvac # list every listening TCP port
11
+ portvac 3000 # show what's on 3000, then kill it (asks first)
12
+ portvac 3000 -k # kill without asking (scripts / CI)
13
+ portvac 3000 -s KILL # SIGKILL instead of the default SIGTERM
14
+ ```
15
+
16
+ Node shop? `npx portvac 3000 -k` works too - same tool, same behaviour.
17
+
18
+ ## Why
19
+
20
+ > "Port 3000 is already in use" - and now you're pasting `lsof -i :3000` into
21
+ > Stack Overflow for the fourth time this week.
22
+
23
+ `lsof` flags differ from `netstat` which differs from Windows `taskkill`. You
24
+ remember none of them under pressure. `portvac` is the one command that works
25
+ the same on macOS, Linux, and Windows: **look first, kill second**, never
26
+ silent.
27
+
28
+ ## How it works
29
+
30
+ 1. **List** - shells out to the platform's own tool (`lsof` / `ss` / `netstat`)
31
+ to read listening sockets. No daemon, no network.
32
+ 2. **Show** - prints a clean table: port, pid, process name, address, family.
33
+ 3. **Vacate** - on a port argument, asks `y/N`, then sends a signal (default
34
+ `TERM`) to the owning pid(s). `-k` skips the prompt.
35
+
36
+ Nothing is installed system-wide and no data leaves your machine.
37
+
38
+ ## Usage
39
+
40
+ ```
41
+ portvac List every TCP port currently listening
42
+ portvac <port> Show what's on <port>, then kill it (asks first)
43
+ portvac <port> --list Show only, don't kill
44
+ portvac <port> -k Kill without confirmation
45
+ portvac <port> -s KILL Send KILL instead of TERM
46
+ portvac --json [<port>] Machine-readable output (great for piping)
47
+
48
+ -k, --force Kill without confirmation
49
+ -l, --list List only; never kill
50
+ -s, --signal <SIG> TERM (default), KILL, INT, QUIT, HUP
51
+ --json Emit JSON; suppresses colors and prompts
52
+ -v, --version
53
+ ```
54
+
55
+ ### JSON
56
+
57
+ ```bash
58
+ $ portvac --json 3000
59
+ {
60
+ "port": 3000,
61
+ "listeners": [
62
+ { "port": 3000, "pid": 12345, "name": "node", "address": "0.0.0.0", "protocol": "tcp", "family": "ipv4" }
63
+ ]
64
+ }
65
+ ```
66
+
67
+ ## Platform notes
68
+
69
+ | Platform | Lookup | Kill |
70
+ |----------|--------|------|
71
+ | macOS / BSD | `lsof -iTCP -sTCP:LISTEN -P -n` | `os.kill(pid, sig)` |
72
+ | Linux | `ss -tlnp` (falls back to `netstat -tlnp`) | `os.kill(pid, sig)` |
73
+ | Windows | `netstat -ano` + `tasklist` | `taskkill /PID <pid> /F /T` |
74
+
75
+ On Windows there's no POSIX signal hierarchy, so `portvac` force-terminates
76
+ (`taskkill /F`). To see another user's processes you may need elevated
77
+ privileges, same as the underlying tools.
78
+
79
+ ## Exit codes
80
+
81
+ | Code | Meaning |
82
+ |------|---------|
83
+ | `0` | listed, or killed successfully |
84
+ | `1` | nothing listening / port not in use / aborted |
85
+ | `2` | error (bad args, tool missing, kill failed) |
86
+
87
+ ## License
88
+
89
+ MIT
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "portvac"
7
+ version = "0.1.0"
8
+ description = "See what's listening on a TCP port and free it - cross-platform, zero dependencies."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "yyfjj" }]
13
+ keywords = ["port", "kill-port", "process", "lsof", "cli", "devtools", "cross-platform", "debugging", "sysadmin", "zero-dependencies"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Intended Audience :: System Administrators",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: POSIX",
21
+ "Operating System :: MacOS",
22
+ "Operating System :: Microsoft :: Windows",
23
+ "Programming Language :: Python :: 3",
24
+ "Topic :: System :: Networking :: Monitoring",
25
+ "Topic :: Utilities",
26
+ ]
27
+ dependencies = []
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/jjdoor/portvac-py"
31
+ Repository = "https://github.com/jjdoor/portvac-py"
32
+ Issues = "https://github.com/jjdoor/portvac-py/issues"
33
+
34
+ [project.scripts]
35
+ portvac = "portvac.cli:main"
36
+
37
+ [tool.setuptools]
38
+ package-dir = { "" = "src" }
39
+
40
+ [tool.setuptools.packages.find]
41
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """portvac - see what's listening on a TCP port and free it. Zero dependencies."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,280 @@
1
+ """portvac command-line interface. Mirrors bin/cli.js behaviour-for-behaviour."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import shutil
7
+ import signal
8
+ import subprocess
9
+ import sys
10
+
11
+ from . import __version__ as VERSION
12
+ from . import core
13
+
14
+ # ---- tiny color helpers (no dep) ----
15
+ _COLOR = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
16
+
17
+
18
+ def _c(code, s):
19
+ return f"\x1b[{code}m{s}\x1b[0m" if _COLOR else s
20
+
21
+
22
+ def red(s): return _c("31", s)
23
+ def green(s): return _c("32", s)
24
+ def yellow(s): return _c("33", s)
25
+ def dim(s): return _c("2", s)
26
+ def bold(s): return _c("1", s)
27
+ def cyan(s): return _c("36", s)
28
+
29
+
30
+ HELP = f"""{bold('portvac')} - see what's listening on a TCP port and free it. Cross-platform, zero deps.
31
+
32
+ {bold('Usage')}
33
+ portvac List every TCP port currently listening
34
+ portvac <port> Show what's on <port>, then kill it (asks first)
35
+ portvac <port> --list Show what's on <port> only, don't kill
36
+ portvac <port> -k Kill without asking (great for scripts)
37
+ portvac <port> -s KILL Use KILL instead of the default TERM
38
+ portvac --json [<port>] Machine-readable output
39
+
40
+ {bold('Flags')}
41
+ -k, --force Kill without confirmation
42
+ -l, --list List only; never kill
43
+ -s, --signal <SIG> Signal to send (TERM, KILL, INT, QUIT, HUP). Default TERM
44
+ --json Emit JSON; suppresses colors and prompts
45
+ -h, --help Show this help
46
+ -v, --version Show version
47
+
48
+ {bold('Exit codes')} 0 ok · 1 nothing listening / aborted · 2 error
49
+
50
+ On macOS/BSD it shells out to {cyan('lsof')}; on Linux to {cyan('ss')} (falling back to
51
+ {cyan('netstat')}); on Windows to {cyan('netstat')} + {cyan('taskkill')}. Nothing is installed
52
+ and no data leaves your machine.
53
+ """
54
+
55
+
56
+ def fail(msg):
57
+ sys.stderr.write(red(f"portvac: {msg}\n"))
58
+ sys.exit(2)
59
+
60
+
61
+ def flag(args, name):
62
+ """Value after --name, or None."""
63
+ if name in args:
64
+ i = args.index(name)
65
+ if i + 1 < len(args):
66
+ return args[i + 1]
67
+ return None
68
+
69
+
70
+ def has(args, name):
71
+ return name in args
72
+
73
+
74
+ # ---- platform detection & listener collection -----------------------------
75
+
76
+ def detect_source() -> str:
77
+ p = sys.platform
78
+ if p in ("darwin", "freebsd", "openbsd"):
79
+ return "lsof"
80
+ if p == "win32":
81
+ return "netstat-windows"
82
+ # linux: prefer ss, fall back to netstat
83
+ if shutil.which("ss"):
84
+ return "ss"
85
+ return "netstat-linux"
86
+
87
+
88
+ def _raw_listeners(source):
89
+ if source == "lsof":
90
+ return subprocess.run(["lsof", "-iTCP", "-sTCP:LISTEN", "-P", "-n"],
91
+ capture_output=True, text=True)
92
+ if source == "ss":
93
+ return subprocess.run(["ss", "-tlnp"], capture_output=True, text=True)
94
+ if source == "netstat-linux":
95
+ return subprocess.run(["netstat", "-tlnp"], capture_output=True, text=True)
96
+ if source == "netstat-windows":
97
+ return subprocess.run(["netstat", "-ano"], capture_output=True, text=True)
98
+ raise ValueError(f"unknown source: {source}")
99
+
100
+
101
+ def _pid_name_map_windows():
102
+ res = subprocess.run(["tasklist", "/FO", "CSV", "/NH"], capture_output=True, text=True)
103
+ m = {}
104
+ if res.returncode != 0 or not res.stdout:
105
+ return m
106
+ for line in res.stdout.split("\n"):
107
+ match = re.match(r'^"([^"]+)","(\d+)"', line)
108
+ if match:
109
+ m[int(match.group(2))] = match.group(1)
110
+ return m
111
+
112
+
113
+ def get_listeners():
114
+ """Collect + parse + dedup listeners. Returns (listeners, warning)."""
115
+ source = detect_source()
116
+ try:
117
+ res = _raw_listeners(source)
118
+ except FileNotFoundError as e:
119
+ return [], f"could not run {source}: {e}. Is the tool installed?"
120
+ raw = res.stdout or ""
121
+ listeners = core.parse_listeners(raw, source)
122
+ if source == "netstat-windows":
123
+ names = _pid_name_map_windows()
124
+ for l in listeners:
125
+ l["name"] = names.get(l["pid"])
126
+ listeners = core.dedup_listeners(listeners)
127
+ return listeners, None
128
+
129
+
130
+ # ---- killing ---------------------------------------------------------------
131
+
132
+ _SIG_CONST = {
133
+ "HUP": signal.SIGHUP,
134
+ "INT": signal.SIGINT,
135
+ "QUIT": signal.SIGQUIT,
136
+ "KILL": signal.SIGKILL,
137
+ "USR1": signal.SIGUSR1,
138
+ "USR2": signal.SIGUSR2,
139
+ "TERM": signal.SIGTERM,
140
+ }
141
+
142
+
143
+ def kill_pids(pids, signal_name):
144
+ results = []
145
+ if sys.platform == "win32":
146
+ # Windows has no POSIX signals; /F forces termination.
147
+ for pid in pids:
148
+ r = subprocess.run(["taskkill", "/PID", str(pid), "/F", "/T"],
149
+ capture_output=True, text=True)
150
+ results.append({"pid": pid, "ok": r.returncode == 0,
151
+ "error": r.stderr.strip() or None})
152
+ return results
153
+ sig = _SIG_CONST[signal_name]
154
+ for pid in pids:
155
+ try:
156
+ os.kill(pid, sig)
157
+ results.append({"pid": pid, "ok": True, "error": None})
158
+ except ProcessLookupError:
159
+ results.append({"pid": pid, "ok": False, "error": "no such process"})
160
+ except PermissionError:
161
+ results.append({"pid": pid, "ok": False, "error": "permission denied"})
162
+ except OSError as e:
163
+ results.append({"pid": pid, "ok": False, "error": str(e)})
164
+ return results
165
+
166
+
167
+ # ---- confirmation prompt ---------------------------------------------------
168
+
169
+ def confirm(question):
170
+ if not sys.stdin.isatty():
171
+ return False
172
+ try:
173
+ ans = input(question).strip().lower()
174
+ except EOFError:
175
+ return False
176
+ return ans in ("y", "yes")
177
+
178
+
179
+ # ---- commands --------------------------------------------------------------
180
+
181
+ def cmd_list(args):
182
+ listeners, warning = get_listeners()
183
+ if warning:
184
+ sys.stderr.write(yellow(f"{warning}\n"))
185
+ if has(args, "--json"):
186
+ sys.stdout.write(json.dumps({"listeners": listeners}, indent=2) + "\n")
187
+ return 0
188
+ if not listeners:
189
+ sys.stdout.write(dim("no TCP ports are listening.\n"))
190
+ return 0
191
+ sys.stdout.write(core.format_table(listeners) + "\n")
192
+ return 0
193
+
194
+
195
+ def cmd_port(port_arg, args):
196
+ try:
197
+ port = core.normalize_port(port_arg)
198
+ except ValueError as e:
199
+ fail(str(e))
200
+
201
+ listeners, warning = get_listeners()
202
+ if warning:
203
+ sys.stderr.write(yellow(f"{warning}\n"))
204
+
205
+ matches = core.select_by_port(listeners, port)
206
+
207
+ if has(args, "--json"):
208
+ sys.stdout.write(json.dumps({"port": port, "listeners": matches}, indent=2) + "\n")
209
+ return 0 if matches else 1
210
+
211
+ if not matches:
212
+ sys.stdout.write(dim(f"nothing listening on port {port}.\n"))
213
+ return 1
214
+
215
+ sys.stdout.write(f"listening on port {bold(str(port))}:\n")
216
+ sys.stdout.write(core.format_table(matches) + "\n")
217
+
218
+ if has(args, "--list") or has(args, "-l"):
219
+ return 0
220
+
221
+ pids = core.unique_pids(matches)
222
+ if not pids:
223
+ sys.stderr.write(yellow(f"found listeners on {port} but no pid to kill (need root?)\n"))
224
+ return 2
225
+
226
+ signal_name = core.parse_signal(flag(args, "--signal") or flag(args, "-s"))
227
+ force = has(args, "-k") or has(args, "--force")
228
+
229
+ if not force:
230
+ parts = []
231
+ for p in pids:
232
+ m = next((x for x in matches if x["pid"] == p), None)
233
+ name = m["name"] if m and m["name"] else "?"
234
+ parts.append(f"{cyan(name)} (pid {p})")
235
+ who = ", ".join(parts)
236
+ if not confirm(f"Kill {who} on port {port} with {signal_name}? [y/N] "):
237
+ sys.stdout.write(dim("aborted. Use -k to skip the prompt.\n"))
238
+ return 1
239
+
240
+ results = kill_pids(pids, signal_name)
241
+ failed = [r for r in results if not r["ok"]]
242
+ for r in results:
243
+ mark = green("✓") if r["ok"] else red("✗")
244
+ tail = "" if r["ok"] else f" - {r['error']}"
245
+ sys.stdout.write(f"{mark} pid {r['pid']} {'killed' if r['ok'] else 'failed'}{tail}\n")
246
+ if failed:
247
+ sys.stderr.write(yellow(
248
+ f"{len(failed)} process(es) could not be killed (try -s KILL or run as root).\n"))
249
+ return 2
250
+ return 0
251
+
252
+
253
+ # ---- main ------------------------------------------------------------------
254
+
255
+ def main(argv=None):
256
+ argv = sys.argv[1:] if argv is None else argv
257
+ if argv and argv[0] in ("-h", "--help"):
258
+ sys.stdout.write(HELP)
259
+ return 0
260
+ if argv and argv[0] in ("-v", "--version"):
261
+ sys.stdout.write(VERSION + "\n")
262
+ return 0
263
+
264
+ try:
265
+ # No args, or a flag-first form like `portvac --json` -> list everything.
266
+ if not argv or argv[0].startswith("-"):
267
+ return cmd_list(argv)
268
+ first = argv[0]
269
+ if first == "list":
270
+ return cmd_list(argv[1:])
271
+ if re.fullmatch(r"\d+", first):
272
+ return cmd_port(first, argv[1:])
273
+ fail(f"unknown argument: {first} (try --help)")
274
+ except ValueError as e:
275
+ fail(str(e))
276
+ return 0
277
+
278
+
279
+ if __name__ == "__main__":
280
+ sys.exit(main())
@@ -0,0 +1,276 @@
1
+ """portvac core - pure listener parsing & selection. No fs, no network, no OS
2
+ calls, no clock. Everything that touches the OS lives in cli.py.
3
+
4
+ A "listener" is a normalized dict:
5
+ {"port": int, "pid": int|None, "name": str|None,
6
+ "address": str, "protocol": "tcp", "family": "ipv4"|"ipv6"}
7
+
8
+ The parsers turn the raw stdout of platform tools (lsof / ss / netstat) into
9
+ this shape so the rest of the tool - and the tests - never care which OS
10
+ produced it. Mirrors src/core.js byte-for-behaviour.
11
+ """
12
+
13
+ import re
14
+
15
+ SIGNALS = {"HUP": 1, "INT": 2, "QUIT": 3, "KILL": 9, "USR1": 10, "USR2": 12, "TERM": 15}
16
+
17
+
18
+ def normalize_port(value) -> int:
19
+ """Coerce a port argument into an int 0-65535. Raises ValueError on garbage."""
20
+ if isinstance(value, bool): # guard: bool is an int subclass
21
+ raise ValueError(f"invalid port: {value!r}")
22
+ if isinstance(value, int):
23
+ if value < 0 or value > 65535:
24
+ raise ValueError(f"invalid port: {value} (use 0-65535)")
25
+ return value
26
+ s = str(value).strip()
27
+ if not re.fullmatch(r"\d+", s):
28
+ raise ValueError(f'invalid port: "{value}" (use 0-65535)')
29
+ n = int(s)
30
+ if n < 0 or n > 65535:
31
+ raise ValueError(f'invalid port: "{value}" (use 0-65535)')
32
+ return n
33
+
34
+
35
+ def parse_signal(value) -> str:
36
+ """Normalize a signal to a canonical uppercase name (no SIG prefix).
37
+
38
+ Accepts "TERM", "SIGTERM", "15". Default (None/"") is "TERM".
39
+ """
40
+ if value is None or value == "":
41
+ return "TERM"
42
+ s = str(value).strip().upper()
43
+ if s.startswith("SIG"):
44
+ s = s[3:]
45
+ if re.fullmatch(r"\d+", s):
46
+ for name, num in SIGNALS.items():
47
+ if num == int(s):
48
+ return name
49
+ raise ValueError(f"unsupported signal: {value!r}")
50
+ if s in SIGNALS:
51
+ return s
52
+ raise ValueError(f'unsupported signal: "{value}" (try TERM, KILL, INT, QUIT, HUP)')
53
+
54
+
55
+ # ----- address helpers ------------------------------------------------------
56
+
57
+ _ADDR_PORT_RE = re.compile(r"^(.*):(\d+)$")
58
+
59
+
60
+ def _split_address_port(addr_port):
61
+ m = _ADDR_PORT_RE.match(addr_port)
62
+ if not m:
63
+ return None
64
+ return m.group(1), int(m.group(2))
65
+
66
+
67
+ def _family_of(address):
68
+ # anything bracketed or containing multiple colons is IPv6; a bare "*" is
69
+ # reported as ipv4 (lsof emits separate IPv4/IPv6 rows for it anyway)
70
+ if address.startswith("[") or (":" in address and not re.fullmatch(r"\d+\.\d+\.\d+\.\d+", address)):
71
+ return "ipv6"
72
+ return "ipv4"
73
+
74
+
75
+ def _normalize_wildcard(address, family):
76
+ if address == "*":
77
+ return "[::]" if family == "ipv6" else "0.0.0.0"
78
+ return address
79
+
80
+
81
+ # ----- parsers --------------------------------------------------------------
82
+
83
+ _LSOF_RE = re.compile(r"^(\S+)\s+(\d+)\s+\S+\s+\S+\s+(IPv4|IPv6)\s+\S+\s+\S+\s+\S+\s+(.+)$")
84
+
85
+
86
+ def parse_lsof(raw) -> list:
87
+ """Parse `lsof -iTCP -sTCP:LISTEN -P -n` output (macOS / BSD / Linux)."""
88
+ out = []
89
+ lines = str(raw).split("\n")
90
+ for line in lines[1:]: # skip header
91
+ t = line.strip()
92
+ if not t:
93
+ continue
94
+ m = _LSOF_RE.match(t)
95
+ if not m:
96
+ continue
97
+ family = m.group(3).lower()
98
+ addr_part = re.sub(r"\s*\(LISTEN\)\s*$", "", m.group(4)).strip()
99
+ sp = _split_address_port(addr_part)
100
+ if not sp:
101
+ continue
102
+ address, port = sp
103
+ out.append({
104
+ "port": port,
105
+ "pid": int(m.group(2)),
106
+ "name": m.group(1),
107
+ "address": _normalize_wildcard(address, family),
108
+ "protocol": "tcp",
109
+ "family": family,
110
+ })
111
+ return out
112
+
113
+
114
+ _SS_RE = re.compile(r"^LISTEN\s+\S+\s+\S+\s+(\S+)\s+\S+\s*(.*)$")
115
+ _SS_PROC_RE = re.compile(r'users:\(\("([^"]+)",pid=(\d+)')
116
+
117
+
118
+ def parse_ss(raw) -> list:
119
+ """Parse `ss -tlnp` output (Linux). Process column is optional."""
120
+ out = []
121
+ for line in str(raw).split("\n")[1:]: # skip header
122
+ t = line.strip()
123
+ if not t:
124
+ continue
125
+ m = _SS_RE.match(t)
126
+ if not m:
127
+ continue
128
+ sp = _split_address_port(m.group(1))
129
+ if not sp:
130
+ continue
131
+ address, port = sp
132
+ family = _family_of(address)
133
+ pid, name = None, None
134
+ pm = _SS_PROC_RE.search(m.group(2))
135
+ if pm:
136
+ name, pid = pm.group(1), int(pm.group(2))
137
+ out.append({
138
+ "port": port,
139
+ "pid": pid,
140
+ "name": name,
141
+ "address": _normalize_wildcard(address, family),
142
+ "protocol": "tcp",
143
+ "family": family,
144
+ })
145
+ return out
146
+
147
+
148
+ _NETSTAT_LINUX_RE = re.compile(r"^tcp\S*\s+\S+\s+\S+\s+(\S+)\s+\S+\s+LISTEN\s+(\S+)$")
149
+
150
+
151
+ def parse_netstat_linux(raw) -> list:
152
+ """Parse `netstat -tlnp` output (Linux)."""
153
+ out = []
154
+ for line in str(raw).split("\n"): # match by pattern; header rows won't match
155
+ t = line.strip()
156
+ if not t:
157
+ continue
158
+ m = _NETSTAT_LINUX_RE.match(t)
159
+ if not m:
160
+ continue
161
+ sp = _split_address_port(m.group(1))
162
+ if not sp:
163
+ continue
164
+ address, port = sp
165
+ family = _family_of(address)
166
+ pid, name = None, None
167
+ pm = re.match(r"^(\d+)/(.+)$", m.group(2))
168
+ if pm:
169
+ pid, name = int(pm.group(1)), pm.group(2)
170
+ out.append({
171
+ "port": port,
172
+ "pid": pid,
173
+ "name": name,
174
+ "address": _normalize_wildcard(address, family),
175
+ "protocol": "tcp",
176
+ "family": family,
177
+ })
178
+ return out
179
+
180
+
181
+ _NETSTAT_WIN_RE = re.compile(r"^TCP\s+(\S+)\s+\S+\s+LISTENING\s+(\d+)$", re.IGNORECASE)
182
+
183
+
184
+ def parse_netstat_windows(raw) -> list:
185
+ """Parse `netstat -ano` output (Windows). Process names stay None here."""
186
+ out = []
187
+ for line in str(raw).split("\n"):
188
+ t = line.strip()
189
+ if not t:
190
+ continue
191
+ m = _NETSTAT_WIN_RE.match(t)
192
+ if not m:
193
+ continue
194
+ sp = _split_address_port(m.group(1))
195
+ if not sp:
196
+ continue
197
+ address, port = sp
198
+ family = _family_of(address)
199
+ out.append({
200
+ "port": port,
201
+ "pid": int(m.group(2)),
202
+ "name": None,
203
+ "address": _normalize_wildcard(address, family),
204
+ "protocol": "tcp",
205
+ "family": family,
206
+ })
207
+ return out
208
+
209
+
210
+ def parse_listeners(raw, source) -> list:
211
+ """Dispatch parser by source descriptor."""
212
+ if source == "lsof":
213
+ return parse_lsof(raw)
214
+ if source == "ss":
215
+ return parse_ss(raw)
216
+ if source == "netstat-linux":
217
+ return parse_netstat_linux(raw)
218
+ if source == "netstat-windows":
219
+ return parse_netstat_windows(raw)
220
+ raise ValueError(f"unknown listener source: {source}")
221
+
222
+
223
+ def dedup_listeners(listeners) -> list:
224
+ """Drop duplicate (port, pid, family) rows - lsof emits one per fd."""
225
+ seen = set()
226
+ out = []
227
+ for l in listeners:
228
+ key = (l["port"], l["pid"], l["family"])
229
+ if key in seen:
230
+ continue
231
+ seen.add(key)
232
+ out.append(l)
233
+ return out
234
+
235
+
236
+ def select_by_port(listeners, port) -> list:
237
+ """Listeners matching a port, sorted by pid (None last)."""
238
+ p = normalize_port(port)
239
+ return sorted(
240
+ [l for l in listeners if l["port"] == p],
241
+ key=lambda l: (l["pid"] is None, l["pid"] if l["pid"] is not None else 0),
242
+ )
243
+
244
+
245
+ def unique_pids(listeners) -> list:
246
+ """Collect unique pids from a set of listeners (for killing)."""
247
+ seen = set()
248
+ out = []
249
+ for l in listeners:
250
+ pid = l["pid"]
251
+ if pid is not None and pid not in seen:
252
+ seen.add(pid)
253
+ out.append(pid)
254
+ return sorted(out)
255
+
256
+
257
+ def format_table(listeners) -> str:
258
+ """Format listeners as a fixed-width human table. Returns '' for empty."""
259
+ if not listeners:
260
+ return ""
261
+ cols = ["port", "pid", "name", "address", "family"]
262
+ rows = [{
263
+ "port": str(l["port"]),
264
+ "pid": str(l["pid"]) if l["pid"] is not None else "-",
265
+ "name": l["name"] or "-",
266
+ "address": l["address"] or "*",
267
+ "family": (l["family"] or "").upper(),
268
+ } for l in listeners]
269
+ w = {c: len(c) for c in cols}
270
+ for r in rows:
271
+ for c in cols:
272
+ w[c] = max(w[c], len(r[c]))
273
+ head = " ".join(c.upper().ljust(w[c]) for c in cols)
274
+ sep = " ".join("-" * w[c] for c in cols)
275
+ body = "\n".join(" ".join(r[c].ljust(w[c]) for c in cols) for r in rows)
276
+ return f"{head}\n{sep}\n{body}"
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: portvac
3
+ Version: 0.1.0
4
+ Summary: See what's listening on a TCP port and free it - cross-platform, zero dependencies.
5
+ Author: yyfjj
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jjdoor/portvac-py
8
+ Project-URL: Repository, https://github.com/jjdoor/portvac-py
9
+ Project-URL: Issues, https://github.com/jjdoor/portvac-py/issues
10
+ Keywords: port,kill-port,process,lsof,cli,devtools,cross-platform,debugging,sysadmin,zero-dependencies
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: POSIX
17
+ Classifier: Operating System :: MacOS
18
+ Classifier: Operating System :: Microsoft :: Windows
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Topic :: System :: Networking :: Monitoring
21
+ Classifier: Topic :: Utilities
22
+ Requires-Python: >=3.8
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Dynamic: license-file
26
+
27
+ # portvac
28
+
29
+ **See what's listening on a TCP port - and free it.** The "port already in use"
30
+ error costs every developer a minute of `lsof` / `netstat` / `taskkill`
31
+ archaeology. `portvac` lists who's on a port in one command, then vacates it on
32
+ confirm. Cross-platform, **zero dependencies**.
33
+
34
+ ```bash
35
+ pip install portvac
36
+ portvac # list every listening TCP port
37
+ portvac 3000 # show what's on 3000, then kill it (asks first)
38
+ portvac 3000 -k # kill without asking (scripts / CI)
39
+ portvac 3000 -s KILL # SIGKILL instead of the default SIGTERM
40
+ ```
41
+
42
+ Node shop? `npx portvac 3000 -k` works too - same tool, same behaviour.
43
+
44
+ ## Why
45
+
46
+ > "Port 3000 is already in use" - and now you're pasting `lsof -i :3000` into
47
+ > Stack Overflow for the fourth time this week.
48
+
49
+ `lsof` flags differ from `netstat` which differs from Windows `taskkill`. You
50
+ remember none of them under pressure. `portvac` is the one command that works
51
+ the same on macOS, Linux, and Windows: **look first, kill second**, never
52
+ silent.
53
+
54
+ ## How it works
55
+
56
+ 1. **List** - shells out to the platform's own tool (`lsof` / `ss` / `netstat`)
57
+ to read listening sockets. No daemon, no network.
58
+ 2. **Show** - prints a clean table: port, pid, process name, address, family.
59
+ 3. **Vacate** - on a port argument, asks `y/N`, then sends a signal (default
60
+ `TERM`) to the owning pid(s). `-k` skips the prompt.
61
+
62
+ Nothing is installed system-wide and no data leaves your machine.
63
+
64
+ ## Usage
65
+
66
+ ```
67
+ portvac List every TCP port currently listening
68
+ portvac <port> Show what's on <port>, then kill it (asks first)
69
+ portvac <port> --list Show only, don't kill
70
+ portvac <port> -k Kill without confirmation
71
+ portvac <port> -s KILL Send KILL instead of TERM
72
+ portvac --json [<port>] Machine-readable output (great for piping)
73
+
74
+ -k, --force Kill without confirmation
75
+ -l, --list List only; never kill
76
+ -s, --signal <SIG> TERM (default), KILL, INT, QUIT, HUP
77
+ --json Emit JSON; suppresses colors and prompts
78
+ -v, --version
79
+ ```
80
+
81
+ ### JSON
82
+
83
+ ```bash
84
+ $ portvac --json 3000
85
+ {
86
+ "port": 3000,
87
+ "listeners": [
88
+ { "port": 3000, "pid": 12345, "name": "node", "address": "0.0.0.0", "protocol": "tcp", "family": "ipv4" }
89
+ ]
90
+ }
91
+ ```
92
+
93
+ ## Platform notes
94
+
95
+ | Platform | Lookup | Kill |
96
+ |----------|--------|------|
97
+ | macOS / BSD | `lsof -iTCP -sTCP:LISTEN -P -n` | `os.kill(pid, sig)` |
98
+ | Linux | `ss -tlnp` (falls back to `netstat -tlnp`) | `os.kill(pid, sig)` |
99
+ | Windows | `netstat -ano` + `tasklist` | `taskkill /PID <pid> /F /T` |
100
+
101
+ On Windows there's no POSIX signal hierarchy, so `portvac` force-terminates
102
+ (`taskkill /F`). To see another user's processes you may need elevated
103
+ privileges, same as the underlying tools.
104
+
105
+ ## Exit codes
106
+
107
+ | Code | Meaning |
108
+ |------|---------|
109
+ | `0` | listed, or killed successfully |
110
+ | `1` | nothing listening / port not in use / aborted |
111
+ | `2` | error (bad args, tool missing, kill failed) |
112
+
113
+ ## License
114
+
115
+ MIT
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/portvac/__init__.py
5
+ src/portvac/__main__.py
6
+ src/portvac/cli.py
7
+ src/portvac/core.py
8
+ src/portvac.egg-info/PKG-INFO
9
+ src/portvac.egg-info/SOURCES.txt
10
+ src/portvac.egg-info/dependency_links.txt
11
+ src/portvac.egg-info/entry_points.txt
12
+ src/portvac.egg-info/top_level.txt
13
+ tests/test_core.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ portvac = portvac.cli:main
@@ -0,0 +1 @@
1
+ portvac
@@ -0,0 +1,187 @@
1
+ import pytest
2
+
3
+ from portvac.core import (
4
+ normalize_port, parse_signal,
5
+ parse_lsof, parse_ss, parse_netstat_linux, parse_netstat_windows, parse_listeners,
6
+ dedup_listeners, select_by_port, unique_pids, format_table,
7
+ )
8
+
9
+ # ----- normalize_port -------------------------------------------------------
10
+
11
+ def test_normalize_port_accepts_ints_and_numeric_strings():
12
+ assert normalize_port(3000) == 3000
13
+ assert normalize_port("8080") == 8080
14
+ assert normalize_port("0") == 0
15
+ assert normalize_port("65535") == 65535
16
+
17
+
18
+ def test_normalize_port_rejects_garbage_and_out_of_range():
19
+ with pytest.raises(ValueError):
20
+ normalize_port("abc")
21
+ with pytest.raises(ValueError):
22
+ normalize_port("-1")
23
+ with pytest.raises(ValueError):
24
+ normalize_port("65536")
25
+ with pytest.raises(ValueError):
26
+ normalize_port("3000a")
27
+
28
+
29
+ # ----- parse_signal ---------------------------------------------------------
30
+
31
+ def test_parse_signal_defaults_to_term_and_strips_sig_prefix():
32
+ assert parse_signal(None) == "TERM"
33
+ assert parse_signal("") == "TERM"
34
+ assert parse_signal("TERM") == "TERM"
35
+ assert parse_signal("sigkill") == "KILL"
36
+ assert parse_signal("KILL") == "KILL"
37
+ assert parse_signal("15") == "TERM" # numeric -> canonical name
38
+ assert parse_signal("9") == "KILL"
39
+
40
+
41
+ def test_parse_signal_rejects_unknown_signals():
42
+ with pytest.raises(ValueError):
43
+ parse_signal("SIGSEGV")
44
+ with pytest.raises(ValueError):
45
+ parse_signal("99")
46
+
47
+
48
+ # ----- parse_lsof (real macOS output) ---------------------------------------
49
+
50
+ LSOF_SAMPLE = """COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
51
+ rapportd 618 benjamin 11u IPv4 0x10415ffa2a699725 0t0 TCP *:59505 (LISTEN)
52
+ rapportd 618 benjamin 13u IPv6 0xfd59be99dbab164 0t0 TCP *:59505 (LISTEN)
53
+ OrbStack 2529 benjamin 82u IPv4 0xbf8bb265f3586c58 0t0 TCP 127.0.0.1:32222 (LISTEN)
54
+ OrbStack 2529 benjamin 83u IPv6 0x34f6f7a71ad73a59 0t0 TCP [::1]:32222 (LISTEN)
55
+ OrbStack 2529 benjamin 115u IPv4 0x36e1f1db735e05 0t0 TCP *:5432 (LISTEN)
56
+ """
57
+
58
+
59
+ def test_parse_lsof_parses_real_macos_output_all_address_shapes():
60
+ ls = parse_lsof(LSOF_SAMPLE)
61
+ assert len(ls) == 5
62
+ assert ls[0] == {"port": 59505, "pid": 618, "name": "rapportd", "address": "0.0.0.0", "protocol": "tcp", "family": "ipv4"}
63
+ assert ls[1] == {"port": 59505, "pid": 618, "name": "rapportd", "address": "[::]", "protocol": "tcp", "family": "ipv6"}
64
+ assert ls[2] == {"port": 32222, "pid": 2529, "name": "OrbStack", "address": "127.0.0.1", "protocol": "tcp", "family": "ipv4"}
65
+ assert ls[3] == {"port": 32222, "pid": 2529, "name": "OrbStack", "address": "[::1]", "protocol": "tcp", "family": "ipv6"}
66
+ assert ls[4] == {"port": 5432, "pid": 2529, "name": "OrbStack", "address": "0.0.0.0", "protocol": "tcp", "family": "ipv4"}
67
+
68
+
69
+ def test_parse_lsof_skips_header_and_blank_lines():
70
+ assert parse_lsof("COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n") == []
71
+ assert parse_lsof("") == []
72
+
73
+
74
+ # ----- parse_ss (Linux) -----------------------------------------------------
75
+
76
+ SS_SAMPLE = """State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
77
+ LISTEN 0 128 0.0.0.0:3000 0.0.0.0:* users:(("node",pid=12345,fd=23))
78
+ LISTEN 0 128 127.0.0.1:5432 0.0.0.0:* users:(("python",pid=12346,fd=4))
79
+ LISTEN 0 128 [::]:8080 [::]:* users:(("nginx",pid=12347,fd=6))
80
+ LISTEN 0 128 0.0.0.0:9000 0.0.0.0:*
81
+ """
82
+
83
+
84
+ def test_parse_ss_parses_linux_output_incl_ipv6_and_missing_process():
85
+ ls = parse_ss(SS_SAMPLE)
86
+ assert len(ls) == 4
87
+ assert ls[0] == {"port": 3000, "pid": 12345, "name": "node", "address": "0.0.0.0", "protocol": "tcp", "family": "ipv4"}
88
+ assert ls[2] == {"port": 8080, "pid": 12347, "name": "nginx", "address": "[::]", "protocol": "tcp", "family": "ipv6"}
89
+ assert ls[3] == {"port": 9000, "pid": None, "name": None, "address": "0.0.0.0", "protocol": "tcp", "family": "ipv4"}
90
+
91
+
92
+ # ----- parse_netstat_linux --------------------------------------------------
93
+
94
+ NETSTAT_LINUX_SAMPLE = """Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
95
+ tcp 0 128 0.0.0.0:3000 0.0.0.0:* LISTEN 12345/node
96
+ tcp 0 128 127.0.0.1:5432 0.0.0.0:* LISTEN 12346/python
97
+ tcp6 0 128 :::8080 :::* LISTEN 12347/nginx
98
+ """
99
+
100
+
101
+ def test_parse_netstat_linux_parses_output():
102
+ ls = parse_netstat_linux(NETSTAT_LINUX_SAMPLE)
103
+ assert len(ls) == 3
104
+ assert ls[0] == {"port": 3000, "pid": 12345, "name": "node", "address": "0.0.0.0", "protocol": "tcp", "family": "ipv4"}
105
+ assert ls[2] == {"port": 8080, "pid": 12347, "name": "nginx", "address": "::", "protocol": "tcp", "family": "ipv6"}
106
+
107
+
108
+ # ----- parse_netstat_windows ------------------------------------------------
109
+
110
+ NETSTAT_WIN_SAMPLE = """Active Connections
111
+
112
+ Proto Local Address Foreign Address State PID
113
+ TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 12345
114
+ TCP 127.0.0.1:5432 0.0.0.0:0 LISTENING 12346
115
+ TCP [::]:8080 [::]:0 LISTENING 12347
116
+ """
117
+
118
+
119
+ def test_parse_netstat_windows_parses_output_name_stays_none():
120
+ ls = parse_netstat_windows(NETSTAT_WIN_SAMPLE)
121
+ assert len(ls) == 3
122
+ assert ls[0] == {"port": 3000, "pid": 12345, "name": None, "address": "0.0.0.0", "protocol": "tcp", "family": "ipv4"}
123
+ assert ls[2] == {"port": 8080, "pid": 12347, "name": None, "address": "[::]", "protocol": "tcp", "family": "ipv6"}
124
+
125
+
126
+ # ----- parse_listeners dispatch --------------------------------------------
127
+
128
+ def test_parse_listeners_dispatches_by_source():
129
+ assert len(parse_listeners(LSOF_SAMPLE, "lsof")) == 5
130
+ assert len(parse_listeners(SS_SAMPLE, "ss")) == 4
131
+ with pytest.raises(ValueError):
132
+ parse_listeners("", "nope")
133
+
134
+
135
+ # ----- dedup_listeners ------------------------------------------------------
136
+
137
+ def test_dedup_collapses_same_port_pid_family():
138
+ ls = parse_lsof(LSOF_SAMPLE)
139
+ dup = ls + [dict(ls[0])]
140
+ assert len(dedup_listeners(dup)) == len(ls)
141
+
142
+
143
+ def test_dedup_keeps_distinct_families_on_same_port():
144
+ ls = dedup_listeners(parse_lsof(LSOF_SAMPLE))
145
+ on59505 = [l for l in ls if l["port"] == 59505]
146
+ assert len(on59505) == 2 # ipv4 + ipv6, same pid
147
+
148
+
149
+ # ----- select_by_port -------------------------------------------------------
150
+
151
+ def test_select_by_port_filters_and_sorts_by_pid():
152
+ ls = dedup_listeners(parse_lsof(LSOF_SAMPLE))
153
+ on32222 = select_by_port(ls, 32222)
154
+ assert len(on32222) == 2
155
+ assert [l["family"] for l in on32222] == ["ipv4", "ipv6"]
156
+ assert select_by_port(ls, 9999) == []
157
+
158
+
159
+ def test_select_by_port_rejects_bad_port():
160
+ with pytest.raises(ValueError):
161
+ select_by_port([], "nope")
162
+
163
+
164
+ # ----- unique_pids ----------------------------------------------------------
165
+
166
+ def test_unique_pids_dedups_and_sorts():
167
+ ls = dedup_listeners(parse_lsof(LSOF_SAMPLE))
168
+ on32222 = select_by_port(ls, 32222)
169
+ assert unique_pids(on32222) == [2529] # both families, one pid
170
+ assert unique_pids([{"pid": 3}, {"pid": 1}, {"pid": 1}]) == [1, 3]
171
+ assert unique_pids([{"pid": None}]) == []
172
+
173
+
174
+ # ----- format_table ---------------------------------------------------------
175
+
176
+ def test_format_table_empty_is_empty_string():
177
+ assert format_table([]) == ""
178
+
179
+
180
+ def test_format_table_renders_header_separator_rows():
181
+ ls = dedup_listeners(parse_lsof(LSOF_SAMPLE))
182
+ table = format_table(select_by_port(ls, 32222))
183
+ assert "PORT" in table
184
+ assert "----" in table
185
+ assert "32222" in table
186
+ assert "OrbStack" in table
187
+ assert len(table.split("\n")) == 4 # header + sep + 2 rows