lsoph 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.
lsoph-0.0.1/LICENSE.md ADDED
@@ -0,0 +1,7 @@
1
+ # WTFPL + Warranty
2
+
3
+ Licensed under the WTFPL with one additional clause:
4
+
5
+ 1. Don't blame me.
6
+
7
+ Do whatever the fuck you want, just don't blame me.
lsoph-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: lsoph
3
+ Version: 0.0.1
4
+ Summary: An interactive lsof alternative
5
+ Author-email: Gareth Davidson <gaz@bitplane.net>
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: Public Domain
10
+ Classifier: Operating System :: POSIX :: Linux
11
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
12
+ License-File: LICENSE.md
13
+ Requires-Dist: psutil~=7.0
14
+ Requires-Dist: textual~=3.1
15
+ Requires-Dist: flake8 ; extra == "dev"
16
+ Requires-Dist: pre-commit ; extra == "dev"
17
+ Requires-Dist: pytest ; extra == "dev"
18
+ Requires-Dist: coverage ; extra == "dev"
19
+ Requires-Dist: pytest-cov ; extra == "dev"
20
+ Requires-Dist: build ; extra == "dev"
21
+ Requires-Dist: twine ; extra == "dev"
22
+ Project-URL: Bug Tracker, https://github.com/bitplane/lsoph/issues
23
+ Project-URL: Homepage, https://bitplane.net/dev/python/lsoph
24
+ Project-URL: Source Code, https://github.com/bitplane/lsoph
25
+ Provides-Extra: dev
26
+
27
+ # ðŸĪ– `lsoph` - list open files using genai slop!
28
+
29
+ TUI that lists open files for a given process.
30
+
31
+ usage:
32
+
33
+ ```shell
34
+ uvx lsoph
35
+ ```
36
+
37
+ ## Why?
38
+ Because I thought it'd only take a couple of hours, and I'd heard good
39
+ things about Gemini 2.5 Pro, so did it as a coding test.
40
+
41
+ It descended into madness over the course of a weekend. Vibe coders,
42
+ I never realised how bad you have it!
43
+
44
+ ### Gemini 2.5 Pro
45
+
46
+ * ☕ Writes more code than a Java consultancy that's paid by LoC.
47
+ * ðŸĪĄ Defends against every type of exception, even import errors; belt,
48
+ braces and elasticated waist.
49
+ * 👖 Its trousers still fall down.
50
+ * ðŸ§ą Hard codes special cases and unreachable logic.
51
+ * ðŸ”Ĩ Will put verbose debug logging in your hottest loops.
52
+ * 🗑 Starts at the complexity ceiling, and manages to climb higher with
53
+ every change.
54
+ * ✅ It needs to be BEST CORRECT, with the pig-headed stubbornness of
55
+ `class UnwaveringPigsHead(basemodel)`.
56
+ * 🖕 Leaves passive aggressive comments in your code if you abuse it enough,
57
+ and doesn't like to tidy up.
58
+ * ðŸŠĶ It can't write test cases, or testable code.
59
+ * ðŸ’Ģ Carried by an enormous context window and rapid generation speed,
60
+ then the wheels come off.
61
+
62
+ ### GPT 4o and 4.5
63
+
64
+ * ðŸ’Đ Can't take the volume of dogshit produced by Gemini (but to be fair who
65
+ can?)
66
+ * ðŸ’Ī Gets lazy because it's got no context window left, or because Sama is
67
+ saving all his GPUs. Probably both.
68
+ * ðŸĨą Attention slips, it forgets where its up to and then hallucinates all
69
+ the details.
70
+ * ðŸĪĨ Sycophantmaxxer, but still ignores your requests.
71
+ * 🎉 Can actually write unit tests.
72
+ * 🚎 Has actually stopped being such an aggressively "safety focused" PR
73
+ bellend.
74
+ * 😎 A classic case of being down with the kids, a move that's absolute chefs
75
+ kiss.
76
+
77
+ ### Claude 3.7
78
+
79
+ * ðŸŦ— It has none of the tools that GPT has, none of the mental models that
80
+ Gemini has.
81
+ * ðŸš― Still pisses all over them from a great height.
82
+ * 💇 Decent eye for aesthetics.
83
+ * 🊟 Has a better window size than GPT, and can focus attention better too.
84
+ * 👉 Mostly does as its told.
85
+ * ðŸ’Đ Still can't write good code.
86
+ * ðŸĪ“ No banter game whatsoever.
87
+
88
+ ## Summary
89
+
90
+ In the kingdom of the token generators, the one-eyed Claude is king.
91
+
92
+
lsoph-0.0.1/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # ðŸĪ– `lsoph` - list open files using genai slop!
2
+
3
+ TUI that lists open files for a given process.
4
+
5
+ usage:
6
+
7
+ ```shell
8
+ uvx lsoph
9
+ ```
10
+
11
+ ## Why?
12
+ Because I thought it'd only take a couple of hours, and I'd heard good
13
+ things about Gemini 2.5 Pro, so did it as a coding test.
14
+
15
+ It descended into madness over the course of a weekend. Vibe coders,
16
+ I never realised how bad you have it!
17
+
18
+ ### Gemini 2.5 Pro
19
+
20
+ * ☕ Writes more code than a Java consultancy that's paid by LoC.
21
+ * ðŸĪĄ Defends against every type of exception, even import errors; belt,
22
+ braces and elasticated waist.
23
+ * 👖 Its trousers still fall down.
24
+ * ðŸ§ą Hard codes special cases and unreachable logic.
25
+ * ðŸ”Ĩ Will put verbose debug logging in your hottest loops.
26
+ * 🗑 Starts at the complexity ceiling, and manages to climb higher with
27
+ every change.
28
+ * ✅ It needs to be BEST CORRECT, with the pig-headed stubbornness of
29
+ `class UnwaveringPigsHead(basemodel)`.
30
+ * 🖕 Leaves passive aggressive comments in your code if you abuse it enough,
31
+ and doesn't like to tidy up.
32
+ * ðŸŠĶ It can't write test cases, or testable code.
33
+ * ðŸ’Ģ Carried by an enormous context window and rapid generation speed,
34
+ then the wheels come off.
35
+
36
+ ### GPT 4o and 4.5
37
+
38
+ * ðŸ’Đ Can't take the volume of dogshit produced by Gemini (but to be fair who
39
+ can?)
40
+ * ðŸ’Ī Gets lazy because it's got no context window left, or because Sama is
41
+ saving all his GPUs. Probably both.
42
+ * ðŸĨą Attention slips, it forgets where its up to and then hallucinates all
43
+ the details.
44
+ * ðŸĪĨ Sycophantmaxxer, but still ignores your requests.
45
+ * 🎉 Can actually write unit tests.
46
+ * 🚎 Has actually stopped being such an aggressively "safety focused" PR
47
+ bellend.
48
+ * 😎 A classic case of being down with the kids, a move that's absolute chefs
49
+ kiss.
50
+
51
+ ### Claude 3.7
52
+
53
+ * ðŸŦ— It has none of the tools that GPT has, none of the mental models that
54
+ Gemini has.
55
+ * ðŸš― Still pisses all over them from a great height.
56
+ * 💇 Decent eye for aesthetics.
57
+ * 🊟 Has a better window size than GPT, and can focus attention better too.
58
+ * 👉 Mostly does as its told.
59
+ * ðŸ’Đ Still can't write good code.
60
+ * ðŸĪ“ No banter game whatsoever.
61
+
62
+ ## Summary
63
+
64
+ In the kingdom of the token generators, the one-eyed Claude is king.
65
+
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "lsoph"
3
+ description = "An interactive lsof alternative"
4
+ version = "0.0.1"
5
+ authors = [
6
+ { name = "Gareth Davidson", email = "gaz@bitplane.net" }
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.10"
10
+
11
+ classifiers = [
12
+ "Programming Language :: Python :: 3",
13
+ "License :: Public Domain", # WTFPL
14
+ "Operating System :: POSIX :: Linux",
15
+ "Topic :: Software Development :: Libraries :: Python Modules",
16
+ ]
17
+
18
+ dependencies = [
19
+ "psutil~=7.0",
20
+ "textual~=3.1"
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ dev = [
25
+ "flake8",
26
+ "pre-commit",
27
+ "pytest",
28
+ "coverage",
29
+ "pytest-cov",
30
+ "build",
31
+ "twine"
32
+ ]
33
+
34
+ [project.urls]
35
+ "Homepage" = "https://bitplane.net/dev/python/lsoph"
36
+ "Source Code" = "https://github.com/bitplane/lsoph"
37
+ "Bug Tracker" = "https://github.com/bitplane/lsoph/issues"
38
+
39
+ [build-system]
40
+ build-backend = "flit_core.buildapi"
41
+ requires = ["flit_core >=3.2,<4"]
42
+
43
+ [project.scripts]
44
+ lsoph = "lsoph.cli:main"
45
+
46
+ [tool.isort]
47
+ profile = "black"
48
+
File without changes
File without changes
@@ -0,0 +1,329 @@
1
+ import logging
2
+ import os
3
+ import re
4
+ import subprocess
5
+ import time
6
+ from typing import Dict, Iterator, List, Optional, Set, Tuple
7
+
8
+ from lsoph.monitor import Monitor
9
+ from lsoph.util.pid import get_descendants
10
+
11
+ # Setup logging - using the same pattern as other modules
12
+ logging.basicConfig(
13
+ level=os.environ.get("LOGLEVEL", "WARNING").upper(),
14
+ format="%(levelname)s:%(name)s:%(message)s",
15
+ )
16
+ log = logging.getLogger("lsoph.backend.lsof") # Use package-aware logger name
17
+
18
+ # Regular expressions for parsing lsof output
19
+ # Format: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
20
+ LSOF_LINE_RE = re.compile(
21
+ r"^(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.+)$"
22
+ )
23
+ FD_TYPE_RE = re.compile(r"(\d+)([rwu])?")
24
+
25
+
26
+ def _parse_fd(fd_str: str) -> Tuple[Optional[int], str]:
27
+ """Parse the FD column from lsof output."""
28
+ if fd_str in ("cwd", "rtd", "txt", "mem"):
29
+ log.debug(f"Special FD type: {fd_str}")
30
+ return None, fd_str
31
+
32
+ match = FD_TYPE_RE.match(fd_str)
33
+ if match:
34
+ fd = int(match.group(1))
35
+ mode = match.group(2) or ""
36
+ log.debug(f"Parsed FD {fd} with mode '{mode}'")
37
+ return fd, mode
38
+
39
+ log.debug(f"Unparsable FD string: {fd_str}")
40
+ return None, fd_str
41
+
42
+
43
+ def _run_lsof(pids: Optional[List[int]] = None) -> Iterator[Dict]:
44
+ """Run lsof and yield parsed output lines."""
45
+ cmd = ["lsof", "-n", "-F", "pcftn"] # p=pid, c=command, f=fd, t=type, n=name
46
+
47
+ if pids:
48
+ cmd.extend(["-p", ",".join(map(str, pids))])
49
+
50
+ log.info(f"Running lsof command: {' '.join(cmd)}")
51
+
52
+ try:
53
+ # Redirect stderr to /dev/null to suppress tracefs warnings
54
+ with open(os.devnull, "w") as devnull:
55
+ proc = subprocess.Popen(
56
+ cmd, stdout=subprocess.PIPE, stderr=devnull, text=True
57
+ )
58
+
59
+ current_record = {}
60
+ for line in proc.stdout:
61
+ line = line.strip()
62
+ if not line:
63
+ continue
64
+
65
+ field_type = line[0]
66
+ value = line[1:]
67
+
68
+ if field_type == "p" and current_record:
69
+ # New process, yield previous record
70
+ yield current_record
71
+ current_record = {"pid": int(value)}
72
+ elif field_type == "p":
73
+ current_record = {"pid": int(value)}
74
+ elif field_type == "c":
75
+ current_record["command"] = value
76
+ elif field_type == "f":
77
+ current_record["fd_str"] = value
78
+ fd, mode = _parse_fd(value)
79
+ current_record["fd"] = fd
80
+ current_record["mode"] = mode
81
+ elif field_type == "t":
82
+ current_record["type"] = value
83
+ elif field_type == "n":
84
+ current_record["path"] = value
85
+ log.debug(
86
+ f"Complete record: PID {current_record.get('pid')}, FD {current_record.get('fd')}, path {value}"
87
+ )
88
+ yield current_record
89
+ current_record = {
90
+ "pid": current_record["pid"],
91
+ "command": current_record["command"],
92
+ }
93
+
94
+ # Yield final record if any
95
+ if current_record and "path" in current_record:
96
+ log.debug(f"Final record: {current_record}")
97
+ yield current_record
98
+
99
+ except subprocess.SubprocessError as e:
100
+ log.error(f"Error running lsof: {e}")
101
+ except Exception as e:
102
+ log.exception(f"Unexpected error in _run_lsof: {e}")
103
+ finally:
104
+ if proc.stdout:
105
+ proc.stdout.close()
106
+ exit_code = proc.wait()
107
+ log.debug(f"lsof process exited with code: {exit_code}")
108
+
109
+
110
+ def _process_lsof_data(record: Dict, monitor: Monitor, timestamp: float) -> None:
111
+ """Process a parsed lsof record and update the monitor."""
112
+ pid = record.get("pid")
113
+ path = record.get("path")
114
+ fd = record.get("fd")
115
+
116
+ if not (pid and path):
117
+ log.debug(f"Incomplete record missing pid or path: {record}")
118
+ return
119
+
120
+ # If fd is None, this is a special file like cwd, not an open fd
121
+ if fd is None:
122
+ fd_str = record.get("fd_str", "unknown")
123
+ log.debug(f"Special file {fd_str} for PID {pid}: {path}")
124
+ monitor.stat(pid, path, True, timestamp)
125
+ return
126
+
127
+ # For regular files, add as open
128
+ log.debug(f"Processing open file: PID {pid}, FD {fd}, path {path}")
129
+ monitor.open(pid, path, fd, True, timestamp)
130
+
131
+ # Check mode to determine if it's being read/written
132
+ mode = record.get("mode", "")
133
+ if "r" in mode:
134
+ log.debug(f"File has read mode: PID {pid}, FD {fd}")
135
+ monitor.read(pid, fd, path, True, timestamp)
136
+ if "w" in mode:
137
+ log.debug(f"File has write mode: PID {pid}, FD {fd}")
138
+ monitor.write(pid, fd, path, True, timestamp)
139
+
140
+
141
+ def _attach_with_descendants(pids: List[int], monitor: Monitor) -> None:
142
+ """
143
+ Attach to processes, their descendants, and monitor their open files using lsof.
144
+ This version tracks child processes by periodically checking for descendants.
145
+ """
146
+ log.info(f"Attaching to initial PIDs: {pids}")
147
+
148
+ # Keep track of all PIDs we're monitoring (including descendants)
149
+ all_pids = set(pids)
150
+
151
+ # Keep track of file descriptors we've seen for each PID
152
+ # to detect closures on subsequent runs
153
+ seen_fds: Dict[int, Set[Tuple[int, str]]] = {}
154
+
155
+ try:
156
+ poll_interval = 1.0 # seconds
157
+ child_check_interval = 5.0 # check for new children every 5 polls
158
+ poll_count = 0
159
+
160
+ log.info(f"Starting lsof polling with interval: {poll_interval} seconds")
161
+
162
+ while True:
163
+ timestamp = time.time()
164
+ poll_count += 1
165
+
166
+ # Check for descendants periodically
167
+ if poll_count % child_check_interval < 1:
168
+ log.debug(f"Checking for new child processes of {pids}")
169
+ new_pids = set()
170
+ for parent_pid in pids:
171
+ try:
172
+ descendants = get_descendants(parent_pid)
173
+ for child_pid in descendants:
174
+ if child_pid not in all_pids:
175
+ log.info(f"Found new child process: {child_pid}")
176
+ new_pids.add(child_pid)
177
+ except Exception as e:
178
+ log.debug(
179
+ f"Error checking descendants for PID {parent_pid}: {e}"
180
+ )
181
+
182
+ # Add any new PIDs to our tracking set
183
+ if new_pids:
184
+ log.info(
185
+ f"Adding {len(new_pids)} new child processes to monitoring"
186
+ )
187
+ all_pids.update(new_pids)
188
+
189
+ # Convert set back to list for lsof
190
+ current_pids = list(all_pids)
191
+ current_fds: Dict[int, Set[Tuple[int, str]]] = {}
192
+
193
+ record_count = 0
194
+ for record in _run_lsof(current_pids):
195
+ record_count += 1
196
+ pid = record.get("pid")
197
+ fd = record.get("fd")
198
+ path = record.get("path")
199
+
200
+ if pid and fd is not None and path:
201
+ if pid not in current_fds:
202
+ current_fds[pid] = set()
203
+ current_fds[pid].add((fd, path))
204
+
205
+ _process_lsof_data(record, monitor, timestamp)
206
+
207
+ log.debug(f"Processed {record_count} lsof records")
208
+
209
+ # Detect closed files (present in seen_fds but not in current_fds)
210
+ close_count = 0
211
+ for pid, fd_paths in seen_fds.items():
212
+ current_pid_fds = current_fds.get(pid, set())
213
+ for fd, path in fd_paths:
214
+ if (fd, path) not in current_pid_fds:
215
+ log.debug(
216
+ f"Detected closed file: PID {pid}, FD {fd}, path {path}"
217
+ )
218
+ monitor.close(pid, fd, True, timestamp)
219
+ close_count += 1
220
+
221
+ if close_count > 0:
222
+ log.debug(f"Detected {close_count} closed files")
223
+
224
+ seen_fds = current_fds
225
+ log.debug(f"Sleeping for {poll_interval} seconds")
226
+ time.sleep(poll_interval)
227
+
228
+ except KeyboardInterrupt:
229
+ log.info("lsof monitoring interrupted by user")
230
+ except Exception as e:
231
+ log.exception(f"Unexpected error in lsof attach: {e}")
232
+
233
+
234
+ def attach(pids: List[int], monitor: Monitor) -> None:
235
+ """
236
+ Standard attach function which monitors the specified PIDs without tracking descendants.
237
+ For command execution with descendants tracking, use _attach_with_descendants.
238
+ """
239
+ log.info(f"Attaching to PIDs: {pids}")
240
+
241
+ # Keep track of file descriptors we've seen for each PID
242
+ # to detect closures on subsequent runs
243
+ seen_fds: Dict[int, Set[Tuple[int, str]]] = {}
244
+
245
+ try:
246
+ poll_interval = 1.0 # seconds
247
+ log.info(f"Starting lsof polling with interval: {poll_interval} seconds")
248
+
249
+ while True:
250
+ timestamp = time.time()
251
+ current_fds: Dict[int, Set[Tuple[int, str]]] = {}
252
+
253
+ record_count = 0
254
+ for record in _run_lsof(pids):
255
+ record_count += 1
256
+ pid = record.get("pid")
257
+ fd = record.get("fd")
258
+ path = record.get("path")
259
+
260
+ if pid and fd is not None and path:
261
+ if pid not in current_fds:
262
+ current_fds[pid] = set()
263
+ current_fds[pid].add((fd, path))
264
+
265
+ _process_lsof_data(record, monitor, timestamp)
266
+
267
+ log.debug(f"Processed {record_count} lsof records")
268
+
269
+ # Detect closed files (present in seen_fds but not in current_fds)
270
+ close_count = 0
271
+ for pid, fd_paths in seen_fds.items():
272
+ current_pid_fds = current_fds.get(pid, set())
273
+ for fd, path in fd_paths:
274
+ if (fd, path) not in current_pid_fds:
275
+ log.debug(
276
+ f"Detected closed file: PID {pid}, FD {fd}, path {path}"
277
+ )
278
+ monitor.close(pid, fd, True, timestamp)
279
+ close_count += 1
280
+
281
+ if close_count > 0:
282
+ log.debug(f"Detected {close_count} closed files")
283
+
284
+ seen_fds = current_fds
285
+ log.debug(f"Sleeping for {poll_interval} seconds")
286
+ time.sleep(poll_interval)
287
+
288
+ except KeyboardInterrupt:
289
+ log.info("lsof monitoring interrupted by user")
290
+ except Exception as e:
291
+ log.exception(f"Unexpected error in lsof attach: {e}")
292
+
293
+
294
+ def run(command: List[str], monitor: Monitor) -> None:
295
+ """Run a command and monitor its open files using lsof."""
296
+ log.info(f"Running command: {' '.join(command)}")
297
+
298
+ proc = None
299
+ try:
300
+ log.debug("Launching subprocess")
301
+ proc = subprocess.Popen(command)
302
+ pid = proc.pid
303
+ log.info(f"Command started with PID: {pid}")
304
+
305
+ # Track the parent process and all its descendants
306
+ pids = [pid]
307
+ # Use a custom attach function to track descendants
308
+ _attach_with_descendants(pids, monitor)
309
+
310
+ except subprocess.SubprocessError as e:
311
+ log.error(f"Error launching command: {e}")
312
+ except Exception as e:
313
+ log.exception(f"Unexpected error in lsof run: {e}")
314
+ finally:
315
+ if proc and proc.poll() is None:
316
+ log.info(f"Terminating command process (PID: {proc.pid})")
317
+ proc.terminate()
318
+ try:
319
+ proc.wait(timeout=1)
320
+ log.debug(
321
+ f"Process terminated gracefully with exit code: {proc.returncode}"
322
+ )
323
+ except subprocess.TimeoutExpired:
324
+ log.debug(
325
+ f"Process did not terminate gracefully, killing PID {proc.pid}"
326
+ )
327
+ proc.kill()
328
+ proc.wait()
329
+ log.debug(f"Process killed with exit code: {proc.returncode}")