memtaskman 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
memtaskman/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """memtaskman: an htop for what's inside your RAM, not just how full it is."""
2
+
3
+ __version__ = "0.1.0"
memtaskman/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from memtaskman.cli import main
2
+
3
+ raise SystemExit(main())
memtaskman/app.py ADDED
@@ -0,0 +1,505 @@
1
+ """Textual TUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections import deque
7
+ from pathlib import PureWindowsPath
8
+
9
+ from rich.console import Group
10
+ from rich.table import Table
11
+ from rich.text import Text
12
+ from textual import work
13
+ from textual.app import App, ComposeResult
14
+ from textual.binding import Binding
15
+ from textual.containers import Grid, Horizontal, Vertical, VerticalScroll
16
+ from textual.screen import ModalScreen
17
+ from textual.widgets import DataTable, Footer, Sparkline, Static
18
+
19
+ from memtaskman.collector import LINUX, WINDOWS, Collector, group_by_app, sort_procs
20
+ from memtaskman.fmt import fmt_bytes, fmt_count_rate, fmt_rate, pct
21
+ from memtaskman.model import DERIVED, HINT, IDENTIFIED, MEASURED, Inspection, ProcessMem, Snapshot, SystemMemory
22
+
23
+ HISTORY = 120
24
+ TOP_N = 50
25
+ INSPECT_EVERY = 5.0
26
+
27
+ C_USED = "#4e9af1"
28
+ C_MOD = "#e5a50a"
29
+ C_STANDBY = "#57a773"
30
+ C_FREE = "#3a3f4b"
31
+ C_COMMIT = "#b48ead"
32
+ C_SWAP = "#d08770"
33
+
34
+ CHARTS = [
35
+ ("used", "RAM in use", "bytes"),
36
+ ("commit", "Commit charge", "bytes"),
37
+ ("cache", "Standby / cache", "bytes"),
38
+ ("compressed", "Compressed", "bytes"),
39
+ ("swap", "Swap / pagefile used", "bytes"),
40
+ ("kernel", "Kernel pools", "bytes"),
41
+ ("faults", "Page faults/s", "rate"),
42
+ ("hard", "Hard faults/s (disk)", "rate"),
43
+ ]
44
+
45
+ LEVEL_STYLE = {MEASURED: "green", DERIVED: "cyan", IDENTIFIED: "magenta", HINT: "yellow"}
46
+
47
+ HELP = f"""\
48
+ [b]memtaskman[/b] — what is inside your RAM
49
+
50
+ [b]System bar[/b]
51
+ [{C_USED}]■ In use[/] pages owned by processes, the kernel and drivers
52
+ [{C_MOD}]■ Modified[/] dirty pages that must be written to disk before reuse
53
+ [{C_STANDBY}]■ Standby/cache[/] file cache; instantly reusable, so it is "available"
54
+ [{C_FREE}]■ Free[/] zeroed / free pages (Linux: MemFree)
55
+ Commit memory promised to processes (RAM + pagefile); hitting the limit = out of memory
56
+ Compressed Windows compression store / Linux zswap pool
57
+
58
+ [b]Process columns[/b]
59
+ Resident working set / RSS: this process's pages in RAM right now (shared pages counted in every sharer)
60
+ Private Windows: private commit (heap, stacks; may be partly paged out)
61
+ Anon Linux: resident anonymous memory (RssAnon)
62
+ PSS Linux: resident, with each shared page split between its sharers; sums to real RAM use
63
+ (read from smaps_rollup for visible rows; blank for other users' processes unless run as root)
64
+ Growth smoothed change of Private per second — a steadily positive value is how leaks look
65
+ Faults/s page faults per second (soft + hard)
66
+ Pool/Swap Windows: kernel pool charged to the process / Linux: swapped-out bytes
67
+
68
+ [b]Inspector[/b] walks the selected process's address space:
69
+ Images exe/DLL/.so code+data, mostly shared with other processes
70
+ Mapped memory-mapped files (fonts, caches, model weights loaded with mmap)
71
+ Shared mem pagefile-backed sections, /dev/shm, memfd
72
+ Private heap, GC heaps, JIT code, anonymous mmap
73
+ Mapped = committed (Win) / mapped (Linux); Resident = in RAM; Shared = resident pages shared with others
74
+ Evidence: [green]measured[/] OS counter · [cyan]derived[/] computed by memtaskman ·
75
+ [magenta]identified[/] from loaded modules · [yellow]hint[/] a configured limit, not usage
76
+
77
+ [b]Keys[/b] ↑/↓ select s sort g group by app a top/all c charts m slim p pause ? help q quit
78
+ [dim]Esc to close[/dim]"""
79
+
80
+
81
+ class HelpScreen(ModalScreen):
82
+ BINDINGS = [Binding("escape,question_mark,q", "app.pop_screen", "Close")]
83
+
84
+ def compose(self) -> ComposeResult:
85
+ with VerticalScroll(id="help"):
86
+ yield Static(HELP)
87
+
88
+
89
+ def stacked_bar(segments: list[tuple[str, int, str]], total: int, width: int) -> Text:
90
+ t = Text()
91
+ width = max(10, width)
92
+ used_cells = 0
93
+ for i, (_label, value, color) in enumerate(segments):
94
+ if i == len(segments) - 1:
95
+ cells = width - used_cells
96
+ else:
97
+ cells = round(width * max(0, value) / total) if total else 0
98
+ cells = min(cells, width - used_cells)
99
+ t.append("█" * cells, style=color)
100
+ used_cells += cells
101
+ return t
102
+
103
+
104
+ def ram_segments(sm: SystemMemory) -> list[tuple[str, int, str]]:
105
+ if sm.standby is not None: # Windows
106
+ mod = sm.modified or 0
107
+ in_use = max(0, sm.used - mod)
108
+ free = sm.free if sm.free is not None else max(0, sm.total - in_use - mod - sm.standby)
109
+ return [("In use", in_use, C_USED), ("Modified", mod, C_MOD), ("Standby", sm.standby, C_STANDBY), ("Free", free, C_FREE)]
110
+ if sm.cached is not None and sm.free is not None: # Linux
111
+ in_use = max(0, sm.total - sm.free - sm.cached)
112
+ return [("In use", in_use, C_USED), ("Cache", sm.cached, C_STANDBY), ("Free", sm.free, C_FREE)]
113
+ return [("Used", sm.used, C_USED), ("Available", sm.available, C_FREE)]
114
+
115
+
116
+ class MemTaskManApp(App):
117
+ TITLE = "memtaskman"
118
+ CSS = """
119
+ Screen { layout: vertical; }
120
+ #top { height: auto; padding: 0 1; }
121
+ #charts { grid-size: 4 2; grid-gutter: 0 2; height: 8; padding: 0 1; }
122
+ #charts.single { grid-size: 1 1; height: 10; }
123
+ .chart { height: 4; }
124
+ #charts.single .chart { height: 10; }
125
+ .chart Sparkline { height: 1fr; }
126
+ .chart .label { height: 1; color: $text-muted; }
127
+ #body { height: 1fr; }
128
+ #procs { width: 1fr; }
129
+ #side { width: 64; border-left: tall $panel; }
130
+ #system { height: auto; padding: 0 1; }
131
+ #inspector { height: 1fr; padding: 0 1; }
132
+ .slim #charts, .slim #side { display: none; }
133
+ HelpScreen { align: center middle; }
134
+ #help { width: 100; max-width: 95%; height: 80%; border: round $accent; background: $surface; padding: 1 2; }
135
+ """
136
+ BINDINGS = [
137
+ Binding("q", "quit", "Quit"),
138
+ Binding("s", "sort", "Sort"),
139
+ Binding("g", "group", "Group"),
140
+ Binding("a", "toggle_all", "Top/All"),
141
+ Binding("c", "charts", "Charts"),
142
+ Binding("m", "mode", "Slim"),
143
+ Binding("p", "pause", "Pause"),
144
+ Binding("question_mark", "help", "Help", key_display="?"),
145
+ ]
146
+
147
+ def __init__(self, interval: float = 1.0, system: bool = True):
148
+ super().__init__()
149
+ self.interval = max(0.25, interval)
150
+ self.collector = Collector(system=system)
151
+ self.history: dict[str, deque[float]] = {k: deque(maxlen=HISTORY) for k, _, _ in CHARTS}
152
+ self.sort_keys = ["resident"] + (["pss"] if LINUX else []) + ["private", "growth", "faults", "name", "pid"]
153
+ self.sort_key = "resident"
154
+ self.show_all = False
155
+ self.grouped = False
156
+ self.paused = False
157
+ self.chart_mode = -1 # -1 = all charts, else index into CHARTS
158
+ self.snapshot: Snapshot | None = None
159
+ self.rows: dict[str, ProcessMem] = {}
160
+ self.selected: str | None = None
161
+ self.last_inspect = 0.0
162
+ self.inspection: Inspection | None = None
163
+
164
+ # ------------------------------------------------------------------ layout
165
+ def compose(self) -> ComposeResult:
166
+ yield Static(id="top")
167
+ with Grid(id="charts"):
168
+ for key, label, _ in CHARTS:
169
+ with Vertical(classes="chart", id=f"chart-{key}"):
170
+ yield Static(label, classes="label", id=f"label-{key}")
171
+ yield Sparkline([], summary_function=max, id=f"spark-{key}")
172
+ with Horizontal(id="body"):
173
+ yield DataTable(id="procs", cursor_type="row", zebra_stripes=True)
174
+ with Vertical(id="side"):
175
+ yield Static(id="system")
176
+ with VerticalScroll(id="inspector"):
177
+ yield Static("Select a process to see what its memory is made of.", id="inspect-body")
178
+ yield Footer()
179
+
180
+ def on_mount(self) -> None:
181
+ self._setup_columns()
182
+ self.set_interval(self.interval, self.tick)
183
+ self.tick()
184
+
185
+ def _setup_columns(self) -> None:
186
+ t = self.query_one("#procs", DataTable)
187
+ t.clear(columns=True)
188
+ pl = self.collector_private_label()
189
+ last = "Pool" if WINDOWS else "Swap"
190
+ first = "Procs" if self.grouped else "PID"
191
+ t.add_column(first, key="pid", width=7)
192
+ t.add_column("Name", key="name", width=26)
193
+ t.add_column("Resident", key="resident", width=9)
194
+ if LINUX:
195
+ t.add_column("PSS", key="pss", width=9)
196
+ t.add_column(pl, key="private", width=9)
197
+ t.add_column("Growth", key="growth", width=10)
198
+ t.add_column("Faults/s", key="faults", width=8)
199
+ t.add_column(last, key="extra", width=7)
200
+ t.add_column("Runtime", key="runtime", width=24)
201
+
202
+ def collector_private_label(self) -> str:
203
+ from memtaskman.collector import private_label
204
+
205
+ return private_label()
206
+
207
+ # ------------------------------------------------------------------ sampling
208
+ def tick(self) -> None:
209
+ if not self.paused:
210
+ self.sample()
211
+
212
+ @work(thread=True, exclusive=True, group="sample")
213
+ def sample(self) -> None:
214
+ snap = self.collector.sample()
215
+ visible = self._ordered(snap.processes)[: TOP_N if not self.show_all else None]
216
+ self.collector.resolve_runtimes(visible + sort_procs(snap.processes, "resident"), time_budget=0.3)
217
+ self.collector.refresh_pss(visible, time_budget=0.2)
218
+ self.call_from_thread(self.apply, snap)
219
+
220
+ def apply(self, snap: Snapshot) -> None:
221
+ self.snapshot = snap
222
+ if snap.system:
223
+ self._push_history(snap.system)
224
+ self.render_top()
225
+ self.render_charts()
226
+ self.render_system()
227
+ self.render_table()
228
+ if self.selected and time.monotonic() - self.last_inspect > INSPECT_EVERY:
229
+ self._inspect_selected()
230
+
231
+ def _push_history(self, sm: SystemMemory) -> None:
232
+ vals = {
233
+ "used": sm.used,
234
+ "commit": sm.commit_used,
235
+ "cache": sm.standby if sm.standby is not None else sm.cached,
236
+ "compressed": sm.compressed,
237
+ "swap": sm.swap_used,
238
+ "kernel": (sm.kernel_paged or 0) + (sm.kernel_nonpaged or 0) if sm.kernel_paged is not None else None,
239
+ "faults": sm.page_faults_ps,
240
+ "hard": sm.hard_faults_ps,
241
+ }
242
+ for k, v in vals.items():
243
+ if v is not None:
244
+ self.history[k].append(float(v))
245
+
246
+ # ------------------------------------------------------------------ rendering
247
+ def render_top(self) -> None:
248
+ snap = self.snapshot
249
+ top = self.query_one("#top", Static)
250
+ width = max(20, top.size.width - 2 if top.size.width else 100)
251
+ lines: list[Text] = []
252
+ status = Text()
253
+ status.append("memtaskman", style="bold")
254
+ status.append(f" sort:{self.sort_key} {'grouped by app' if self.grouped else 'per process'}"
255
+ f" {'all' if self.show_all else f'top {TOP_N}'} {len(snap.processes)} processes", style="dim")
256
+ if self.paused:
257
+ status.append(" PAUSED", style="bold yellow")
258
+ lines.append(status)
259
+ sm = snap.system
260
+ if sm:
261
+ bar_w = width - 30
262
+ segs = ram_segments(sm)
263
+ line = Text(f"{'RAM':<7}")
264
+ line.append_text(stacked_bar(segs, sm.total, bar_w))
265
+ line.append(f" {fmt_bytes(sm.used)}/{fmt_bytes(sm.total)} {pct(sm.used, sm.total):.0f}%")
266
+ lines.append(line)
267
+ legend = Text(" " * 7)
268
+ for label, value, color in segs:
269
+ legend.append("■ ", style=color)
270
+ legend.append(f"{label} {fmt_bytes(value)} ")
271
+ if sm.compressed is not None:
272
+ legend.append(f"Compressed {fmt_bytes(sm.compressed)} ", style="dim")
273
+ lines.append(legend)
274
+ if sm.commit_used is not None and sm.commit_limit:
275
+ line = Text(f"{'Commit':<7}")
276
+ line.append_text(stacked_bar([("c", sm.commit_used, C_COMMIT), ("r", sm.commit_limit - sm.commit_used, C_FREE)],
277
+ sm.commit_limit, bar_w))
278
+ line.append(f" {fmt_bytes(sm.commit_used)}/{fmt_bytes(sm.commit_limit)} {pct(sm.commit_used, sm.commit_limit):.0f}%")
279
+ lines.append(line)
280
+ if sm.swap_total:
281
+ line = Text(f"{'Swap':<7}")
282
+ line.append_text(stacked_bar([("s", sm.swap_used or 0, C_SWAP), ("f", sm.swap_total - (sm.swap_used or 0), C_FREE)],
283
+ sm.swap_total, bar_w))
284
+ line.append(f" {fmt_bytes(sm.swap_used)}/{fmt_bytes(sm.swap_total)}")
285
+ lines.append(line)
286
+ top.update(Text("\n").join(lines))
287
+
288
+ def render_charts(self) -> None:
289
+ for i, (key, label, kind) in enumerate(CHARTS):
290
+ data = list(self.history[key])
291
+ cell = self.query_one(f"#chart-{key}")
292
+ cell.display = self.chart_mode in (-1, i)
293
+ if not cell.display:
294
+ continue
295
+ cur = data[-1] if data else None
296
+ if kind == "bytes":
297
+ val = fmt_bytes(cur)
298
+ rng = f" [{fmt_bytes(min(data))}-{fmt_bytes(max(data))}]" if len(data) > 1 else ""
299
+ else:
300
+ val = fmt_count_rate(cur)
301
+ rng = f" [peak {fmt_count_rate(max(data))}]" if data else ""
302
+ self.query_one(f"#label-{key}", Static).update(f"{label}: [b]{val}[/b][dim]{rng}[/dim]")
303
+ self.query_one(f"#spark-{key}", Sparkline).data = data or [0]
304
+
305
+ def render_system(self) -> None:
306
+ sm = self.snapshot.system if self.snapshot else None
307
+ w = self.query_one("#system", Static)
308
+ if not sm:
309
+ w.update("[dim]system counters disabled[/dim]")
310
+ return
311
+ t = Table.grid(padding=(0, 1))
312
+ t.add_column(style="dim")
313
+ t.add_column(justify="right")
314
+ t.add_column(style="dim")
315
+ rows = [
316
+ ("Available", sm.available, "free + reclaimable cache"),
317
+ ("Modified" if WINDOWS else "Dirty", sm.modified, "waiting to be written"),
318
+ ("Standby" if WINDOWS else "Page cache", sm.standby if WINDOWS else sm.cached, "cached file data"),
319
+ ("Kernel paged" if WINDOWS else "Slab reclaim", sm.kernel_paged, ""),
320
+ ("Kernel nonpaged" if WINDOWS else "Slab unreclaim", sm.kernel_nonpaged, "can't be paged out"),
321
+ ("Compressed", sm.compressed, "compression store" if WINDOWS else "zswap pool"),
322
+ ]
323
+ rows += [(k, v, "") for k, v in sm.extra.items()]
324
+ for label, value, note in rows:
325
+ if value is not None:
326
+ t.add_row(label, fmt_bytes(value), note)
327
+ w.update(Group(Text("System", style="bold"), t))
328
+
329
+ def _ordered(self, procs: list[ProcessMem]) -> list[ProcessMem]:
330
+ if self.grouped:
331
+ procs = group_by_app(procs)
332
+ return sort_procs(procs, self.sort_key)
333
+
334
+ def _row_key(self, p: ProcessMem) -> str:
335
+ return f"g:{p.name.lower()}" if self.grouped else f"p:{p.pid}:{p.create_time}"
336
+
337
+ def render_table(self) -> None:
338
+ t = self.query_one("#procs", DataTable)
339
+ procs = self._ordered(self.snapshot.processes)
340
+ if not self.show_all:
341
+ procs = procs[:TOP_N]
342
+ if self.selected and not any(self._row_key(p) == self.selected for p in procs):
343
+ # Keep the selected process visible even when it drops out of the top N.
344
+ sel = next((p for p in self._ordered(self.snapshot.processes) if self._row_key(p) == self.selected), None)
345
+ if sel:
346
+ procs.append(sel)
347
+ scroll_y = t.scroll_y
348
+ t.clear()
349
+ self.rows = {}
350
+ for p in procs:
351
+ k = self._row_key(p)
352
+ self.rows[k] = p
353
+ growth = Text(fmt_rate(p.growth_ps), justify="right")
354
+ if p.growth_ps and p.growth_ps > 1024 * 1024:
355
+ growth.stylize("bold red")
356
+ elif p.growth_ps and p.growth_ps > 64 * 1024:
357
+ growth.stylize("yellow")
358
+ elif p.growth_ps and p.growth_ps < -64 * 1024:
359
+ growth.stylize("green")
360
+ extra = (p.paged_pool or 0) + (p.nonpaged_pool or 0) if WINDOWS and p.paged_pool is not None else p.swapped
361
+ rt = f"{p.runtime or ''} {p.runtime_detail or ''}".strip()
362
+ cells = [
363
+ Text(f"×{p.count}" if self.grouped else str(p.pid), justify="right"),
364
+ p.name,
365
+ Text(fmt_bytes(p.resident), justify="right"),
366
+ ]
367
+ if LINUX:
368
+ cells.append(Text(fmt_bytes(p.pss), justify="right"))
369
+ t.add_row(
370
+ *cells,
371
+ Text(fmt_bytes(p.private), justify="right"),
372
+ growth,
373
+ Text(fmt_count_rate(p.faults_ps), justify="right"),
374
+ Text(fmt_bytes(extra) if extra is not None else "-", justify="right"),
375
+ Text(rt, style="magenta" if p.runtime else ""),
376
+ key=k,
377
+ )
378
+ if self.selected in self.rows:
379
+ t.move_cursor(row=t.get_row_index(self.selected), animate=False, scroll=False)
380
+ t.scroll_to(y=scroll_y, animate=False)
381
+
382
+ # ------------------------------------------------------------------ inspector
383
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
384
+ t = event.data_table
385
+ key = event.row_key.value if event.row_key else None
386
+ # Ignore stale events from clear()/rebuild: only the row under the cursor counts.
387
+ try:
388
+ current = t.coordinate_to_cell_key(t.cursor_coordinate).row_key.value
389
+ except Exception: # noqa: BLE001 - empty table
390
+ return
391
+ if key != current or key == self.selected:
392
+ return
393
+ self.selected = key
394
+ self.query_one("#inspect-body", Static).update(Text(f"Inspecting {self.rows[key].name} …", style="dim"))
395
+ self._inspect_selected()
396
+
397
+ def _inspect_selected(self) -> None:
398
+ if not self.selected or not self.snapshot:
399
+ return
400
+ p = self.rows.get(self.selected)
401
+ if p is None:
402
+ return
403
+ if self.grouped:
404
+ members = [q for q in self.snapshot.processes if q.name.lower() == p.name.lower()]
405
+ if not members:
406
+ return
407
+ target = max(members, key=lambda q: q.resident)
408
+ else:
409
+ target = p
410
+ self.last_inspect = time.monotonic()
411
+ self.inspect_worker(target, p.count)
412
+
413
+ @work(thread=True, exclusive=True, group="inspect")
414
+ def inspect_worker(self, p: ProcessMem, group_count: int) -> None:
415
+ ins = self.collector.inspect(p)
416
+ self.call_from_thread(self.show_inspection, ins, group_count)
417
+
418
+ def show_inspection(self, ins: Inspection, group_count: int = 1) -> None:
419
+ self.inspection = ins
420
+ parts: list = []
421
+ head = Text()
422
+ head.append(f"{ins.name}", style="bold")
423
+ head.append(f" pid {ins.pid}", style="dim")
424
+ if group_count > 1:
425
+ head.append(f" (largest of {group_count})", style="dim")
426
+ parts.append(head)
427
+ if ins.error:
428
+ parts.append(Text(ins.error, style="red"))
429
+ if ins.categories:
430
+ total_res = sum(c.resident or 0 for c in ins.categories) or 1
431
+ t = Table(box=None, padding=(0, 1), show_edge=False, header_style="bold dim")
432
+ t.add_column("What", no_wrap=True)
433
+ t.add_column("Mapped", justify="right")
434
+ t.add_column("Resident", justify="right")
435
+ t.add_column("Shared", justify="right")
436
+ t.add_column("", width=8, no_wrap=True)
437
+ for c in ins.categories:
438
+ share = (c.resident or 0) / total_res
439
+ bar = Text("█" * max(0, round(share * 8)), style=C_USED)
440
+ t.add_row(c.name, fmt_bytes(c.mapped), fmt_bytes(c.resident), fmt_bytes(c.resident_shared), bar)
441
+ if ins.reserved:
442
+ t.add_row(Text("Reserved only", style="dim"), Text(fmt_bytes(ins.reserved), style="dim"), "", "", "")
443
+ parts.append(t)
444
+ if ins.top_files:
445
+ parts.append(Text("\nLargest file-backed pages", style="bold"))
446
+ ft = Table(box=None, padding=(0, 1), show_edge=False, show_header=False)
447
+ ft.add_column(justify="right")
448
+ ft.add_column(no_wrap=True, overflow="ellipsis")
449
+ for f in ins.top_files:
450
+ pp = PureWindowsPath(f.path)
451
+ label = Text(pp.name)
452
+ parent = str(pp.parent)
453
+ label.append(" " + (parent if len(parent) <= 30 else "…" + parent[-29:]), style="dim")
454
+ ft.add_row(fmt_bytes(f.resident if f.resident is not None else f.mapped), label)
455
+ parts.append(ft)
456
+ if ins.evidence:
457
+ parts.append(Text("\nEvidence", style="bold"))
458
+ for level, text in ins.evidence:
459
+ line = Text()
460
+ line.append(f"{level:<10}", style=LEVEL_STYLE.get(level, ""))
461
+ line.append(text)
462
+ parts.append(line)
463
+ if ins.cmdline:
464
+ parts.append(Text("\n" + " ".join(ins.cmdline)[:300], style="dim"))
465
+ parts.append(Text(f"\ninspected in {ins.elapsed * 1000:.0f} ms, refreshes every {INSPECT_EVERY:.0f}s", style="dim"))
466
+ self.query_one("#inspect-body", Static).update(Group(*parts))
467
+
468
+ # ------------------------------------------------------------------ actions
469
+ def _refresh_now(self) -> None:
470
+ if self.snapshot:
471
+ self.render_top()
472
+ self.render_table()
473
+
474
+ def action_sort(self) -> None:
475
+ self.sort_key = self.sort_keys[(self.sort_keys.index(self.sort_key) + 1) % len(self.sort_keys)]
476
+ self.notify(f"sort: {self.sort_key}", timeout=1.5)
477
+ self._refresh_now()
478
+
479
+ def action_group(self) -> None:
480
+ self.grouped = not self.grouped
481
+ self.selected = None
482
+ self._setup_columns()
483
+ self._refresh_now()
484
+
485
+ def action_toggle_all(self) -> None:
486
+ self.show_all = not self.show_all
487
+ self._refresh_now()
488
+
489
+ def action_charts(self) -> None:
490
+ self.chart_mode = self.chart_mode + 1 if self.chart_mode + 1 < len(CHARTS) else -1
491
+ self.query_one("#charts").set_class(self.chart_mode >= 0, "single")
492
+ self.render_charts()
493
+
494
+ def action_mode(self) -> None:
495
+ self.screen.toggle_class("slim")
496
+
497
+ def action_pause(self) -> None:
498
+ self.paused = not self.paused
499
+ self._refresh_now()
500
+
501
+ def action_help(self) -> None:
502
+ self.push_screen(HelpScreen())
503
+
504
+ def on_unmount(self) -> None:
505
+ self.collector.close()
memtaskman/cli.py ADDED
@@ -0,0 +1,135 @@
1
+ """Command-line entry point: `memtaskman` / `mtm`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ import time
9
+
10
+ from memtaskman import __version__
11
+
12
+
13
+ def _snapshot(args, collector=None):
14
+ from memtaskman.collector import Collector, sort_procs
15
+
16
+ c = collector or Collector(system=not args.no_system)
17
+ c.sample()
18
+ time.sleep(args.interval)
19
+ snap = c.sample()
20
+ top = sort_procs(snap.processes, "resident")
21
+ c.resolve_runtimes(top[:40], time_budget=2.0)
22
+ c.refresh_pss(top if args.all else top[: args.top], time_budget=2.0)
23
+ snap = c.sample()
24
+ return c, snap
25
+
26
+
27
+ def cmd_main(args) -> int:
28
+ if args.once or args.json:
29
+ from memtaskman import report
30
+
31
+ c, snap = _snapshot(args)
32
+ c.close()
33
+ if not args.all:
34
+ snap.processes = sorted(snap.processes, key=lambda p: p.resident, reverse=True)[: args.top]
35
+ print(report.to_json(snap) if args.json else report.render_text(snap, top=None))
36
+ return 0
37
+ from memtaskman.app import MemTaskManApp
38
+
39
+ MemTaskManApp(interval=args.interval, system=not args.no_system).run()
40
+ return 0
41
+
42
+
43
+ def cmd_inspect(args) -> int:
44
+ import psutil
45
+
46
+ from memtaskman import report
47
+ from memtaskman.collector import Collector
48
+
49
+ c = Collector(system=False)
50
+ snap = c.sample()
51
+ p = next((p for p in snap.processes if p.pid == args.pid), None)
52
+ if p is None:
53
+ try:
54
+ psutil.Process(args.pid)
55
+ print(f"pid {args.pid}: access denied", file=sys.stderr)
56
+ except psutil.NoSuchProcess:
57
+ print(f"pid {args.pid}: no such process", file=sys.stderr)
58
+ return 1
59
+ ins = c.inspect(p)
60
+ print(report.to_json(ins) if args.json else report.render_inspection(ins))
61
+ return 0 if not ins.error else 2
62
+
63
+
64
+ def cmd_diff(args) -> int:
65
+ from memtaskman.diff import diff_snapshots
66
+
67
+ with open(args.a, encoding="utf-8") as fa, open(args.b, encoding="utf-8") as fb:
68
+ a, b = json.load(fa), json.load(fb)
69
+ print(diff_snapshots(a, b, top=args.top))
70
+ return 0
71
+
72
+
73
+ def cmd_record(args) -> int:
74
+ from memtaskman.collector import Collector
75
+
76
+ c = Collector(system=not args.no_system)
77
+ c.sample()
78
+ n = 0
79
+ try:
80
+ with open(args.out, "a", encoding="utf-8") as f:
81
+ while args.count == 0 or n < args.count:
82
+ time.sleep(args.interval)
83
+ snap = c.sample()
84
+ if not args.all:
85
+ snap.processes = sorted(snap.processes, key=lambda p: p.resident, reverse=True)[: args.top]
86
+ f.write(json.dumps(snap.to_dict(), separators=(",", ":")) + "\n")
87
+ f.flush()
88
+ n += 1
89
+ print(f"\rrecorded {n} snapshot(s) to {args.out}", end="", file=sys.stderr)
90
+ except KeyboardInterrupt:
91
+ pass
92
+ finally:
93
+ c.close()
94
+ print(file=sys.stderr)
95
+ return 0
96
+
97
+
98
+ def build_parser() -> argparse.ArgumentParser:
99
+ p = argparse.ArgumentParser(prog="memtaskman", description="An htop for what's inside your RAM, not just how full it is.")
100
+ p.add_argument("--version", action="version", version=f"memtaskman {__version__}")
101
+ p.add_argument("-i", "--interval", type=float, default=1.0, help="sample interval in seconds (default 1)")
102
+ p.add_argument("--once", action="store_true", help="print one text report and exit")
103
+ p.add_argument("--json", action="store_true", help="print one JSON snapshot and exit")
104
+ p.add_argument("--top", type=int, default=25, help="processes in --once/--json/record output (default 25)")
105
+ p.add_argument("--all", action="store_true", help="include every process in --once/--json/record output")
106
+ p.add_argument("--no-system", action="store_true", help="skip system-wide counters")
107
+ sub = p.add_subparsers(dest="cmd")
108
+
109
+ ins = sub.add_parser("inspect", help="break down one process's address space")
110
+ ins.add_argument("pid", type=int)
111
+ ins.add_argument("--json", action="store_true")
112
+ ins.set_defaults(func=cmd_inspect)
113
+
114
+ d = sub.add_parser("diff", help="compare two --json snapshots")
115
+ d.add_argument("a")
116
+ d.add_argument("b")
117
+ d.add_argument("--top", type=int, default=20)
118
+ d.set_defaults(func=cmd_diff)
119
+
120
+ r = sub.add_parser("record", help="append snapshots to a JSON Lines file")
121
+ r.add_argument("out")
122
+ r.add_argument("-i", "--interval", type=float, default=5.0)
123
+ r.add_argument("-n", "--count", type=int, default=0, help="stop after N snapshots (0 = until Ctrl+C)")
124
+ r.add_argument("--top", type=int, default=50)
125
+ r.add_argument("--all", action="store_true")
126
+ r.add_argument("--no-system", action="store_true")
127
+ r.set_defaults(func=cmd_record)
128
+ return p
129
+
130
+
131
+ def main(argv: list[str] | None = None) -> int:
132
+ args = build_parser().parse_args(argv)
133
+ if getattr(args, "func", None):
134
+ return args.func(args)
135
+ return cmd_main(args)