sot 1.0.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.
sot/__about__.py ADDED
@@ -0,0 +1,4 @@
1
+ from datetime import datetime
2
+
3
+ __version__ = "1.0.0"
4
+ __current_year__ = datetime.now().year
sot/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from .__about__ import __version__, __current_year__
2
+ from ._app import run
3
+ from .blockchar_stream import BlockCharStream
4
+ from .braille_stream import BrailleStream
5
+
6
+ __all__ = ["BrailleStream", "BlockCharStream", "run", "__version__", "__current_year__"]
sot/_app.py ADDED
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from sys import version_info
5
+
6
+ from textual.app import App
7
+
8
+ from .__about__ import __version__, __current_year__
9
+ from ._cpu import CPU
10
+ from ._disk import Disk
11
+ from ._info import InfoLine
12
+ from ._mem import Mem
13
+ from ._net import Net
14
+ from ._procs_list import ProcsList
15
+ # from ._sot import Sot
16
+
17
+ def run(argv=None):
18
+ parser = argparse.ArgumentParser(
19
+ description="Command-line System Obervation Tool ≈",
20
+ formatter_class=argparse.RawTextHelpFormatter,
21
+ add_help=False
22
+ )
23
+
24
+ parser.add_argument(
25
+ "--help",
26
+ "-H",
27
+ action="help",
28
+ default=argparse.SUPPRESS,
29
+ help="Show this help message and exit."
30
+ )
31
+
32
+ parser.add_argument(
33
+ "--version",
34
+ "-V",
35
+ action="version",
36
+ version=_get_version_text(),
37
+ help="Display version information",
38
+ )
39
+
40
+ parser.add_argument(
41
+ "--log",
42
+ "-L",
43
+ type=str,
44
+ default=None,
45
+ help="Debug log file",
46
+ )
47
+
48
+ parser.add_argument(
49
+ "--net",
50
+ "-N",
51
+ type=str,
52
+ default=None,
53
+ help="Network interface to display (default: auto)",
54
+ )
55
+
56
+ args = parser.parse_args(argv)
57
+
58
+ # with a grid
59
+ class sotApp(App):
60
+ async def on_mount(self) -> None:
61
+ grid = await self.view.dock_grid(edge="left")
62
+
63
+ grid.add_column(fraction=36, name="left")
64
+ # grid.add_column(fraction=15, name="middle")
65
+ # grid.add_column(fraction=36, name="right")
66
+ grid.add_column(fraction=45, name="right")
67
+
68
+ grid.add_row(size=1, name="r0")
69
+ grid.add_row(fraction=1, name="r1")
70
+ grid.add_row(fraction=1, name="r2")
71
+ grid.add_row(fraction=1, name="r3")
72
+ grid.add_areas(
73
+ area0="left-start|right-end,r0",
74
+ area1="left,r1",
75
+ # area2a="middle,r1-start|r1-end",
76
+ # area2b="middle,r2-start|r2-end",
77
+ # area2c="middle,r3-start|r3-end",
78
+ area3a="right,r1",
79
+ area3b="right,r2",
80
+ area3c="right,r3",
81
+ area4="left,r2-start|r3-end",
82
+ )
83
+ grid.place(
84
+ area0=InfoLine(),
85
+ area1=CPU(),
86
+ # area2b=Sot(),
87
+ area3a=Mem(),
88
+ area3b=Disk(),
89
+ area3c=Net(args.net),
90
+ area4=ProcsList(),
91
+ )
92
+
93
+ async def on_load(self, _):
94
+ await self.bind("q", "quit", "quit")
95
+
96
+ sotApp.run(log=args.log)
97
+
98
+
99
+ def _get_version_text():
100
+ python_version = f"{version_info.major}.{version_info.minor}.{version_info.micro}"
101
+
102
+ return "\n".join(
103
+ [
104
+ f"sot {__version__} [Python {python_version}]",
105
+ f"Copyright (c) 2024-{__current_year__} Kumar Anirudha",
106
+ ]
107
+ )
sot/_battery.py ADDED
@@ -0,0 +1,62 @@
1
+ import psutil
2
+ from rich import box
3
+ from rich.panel import Panel
4
+ from rich.text import Text
5
+ from textual.widget import Widget
6
+
7
+ from .braille_stream import BrailleStream
8
+
9
+
10
+ class Battery(Widget):
11
+ def on_mount(self):
12
+ self.bat_stream = BrailleStream(40, 3, 0.0, 100.0)
13
+
14
+ self.panel = Panel(
15
+ "",
16
+ title="",
17
+ title_align="left",
18
+ # border_style="yellow",
19
+ border_style="bright_black",
20
+ box=box.SQUARE,
21
+ )
22
+ self.collect_data()
23
+ # update frequency: 1 min
24
+ self.set_interval(60.0, self.collect_data)
25
+
26
+ def collect_data(self):
27
+ bat = psutil.sensors_battery()
28
+ assert bat is not None
29
+
30
+ self.bat_stream.add_value(bat.percent)
31
+ self.refresh_graph()
32
+
33
+ if bat.power_plugged:
34
+ status = "charging"
35
+ else:
36
+ mm = bat.secsleft // 60
37
+ hh, mm = divmod(mm, 60)
38
+ time_left_str = []
39
+ if hh > 0:
40
+ time_left_str.append(f"{hh}h")
41
+ if mm > 0:
42
+ time_left_str.append(f"{mm}min")
43
+ status = " ".join(time_left_str) + " left"
44
+
45
+ title = f"[b]battery[/] - {self.bat_stream.values[-1]:.1f}% - {status}"
46
+ if bat.percent < 15 and not bat.power_plugged:
47
+ title = "[red3 reverse bold]" + title + "[/]"
48
+
49
+ self.panel.title = title
50
+
51
+ self.refresh()
52
+
53
+ def refresh_graph(self):
54
+ self.panel.renderable = Text("\n".join(self.bat_stream.graph), style="yellow")
55
+
56
+ def render(self) -> Panel:
57
+ return self.panel
58
+
59
+ async def on_resize(self, event):
60
+ self.bat_stream.reset_width(event.width - 4)
61
+ self.bat_stream.reset_height(event.height - 2)
62
+ self.refresh_graph()
sot/_cpu.py ADDED
@@ -0,0 +1,346 @@
1
+ import re
2
+ from pathlib import Path
3
+
4
+ import psutil
5
+ from rich import box
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+ from rich.text import Text
9
+ from textual.widget import Widget
10
+
11
+ from .braille_stream import BrailleStream
12
+
13
+
14
+ def val_to_color(val: float, minval: float, maxval: float) -> str:
15
+ t = (val - minval) / (maxval - minval)
16
+ k = round(t * 3)
17
+ return {0: "yellow", 1: "dark_orange", 2: "sky_blue3", 3: "aquamarine3"}[k]
18
+
19
+
20
+ # https://stackoverflow.com/a/312464/353337
21
+ def chunks(lst, n):
22
+ for i in range(0, len(lst), n):
23
+ yield lst[i : i + n]
24
+
25
+
26
+ # https://stackoverflow.com/a/6473724/353337
27
+ def transpose(lst):
28
+ return list(map(list, zip(*lst)))
29
+
30
+
31
+ def flatten(lst):
32
+ return [item for sublist in lst for item in sublist]
33
+
34
+
35
+ def get_cpu_model():
36
+ # cpuinfo does computation in addition to reading data, see
37
+ # <https://github.com/workhorsy/py-cpuinfo/issues/155#issuecomment-678923252>.
38
+ # Try to work around this.
39
+ # TODO keep an eye on the above bug.
40
+ try:
41
+ # On Linux, the file contains lines like
42
+ # ```
43
+ # model name : Intel(R) Core(TM) i5-8350U CPU @ 1.70GHz
44
+ # ```
45
+ with open("/proc/cpuinfo") as f:
46
+ content = f.read()
47
+ m = re.search(r"model name\t: (.*)", content)
48
+ model_name = m.group(1)
49
+ except Exception:
50
+ import cpuinfo
51
+
52
+ model_name = cpuinfo.get_cpu_info()["brand_raw"]
53
+ return model_name
54
+
55
+
56
+ def get_current_temps():
57
+ # First try manually reading the temperatures. (pyutil is slow.)
58
+ for key in ["coretemp", "k10temp"]:
59
+ path = Path(f"/sys/devices/platform/{key}.0/hwmon/hwmon6/")
60
+ if not path.is_dir():
61
+ continue
62
+
63
+ k = 1
64
+ temps = []
65
+ while True:
66
+ file = path / f"temp{k}_input"
67
+ if not file.exists():
68
+ break
69
+ with open(file) as f:
70
+ content = f.read()
71
+ temps.append(int(content) / 1000)
72
+ k += 1
73
+
74
+ return temps
75
+
76
+ # Not try psutil.sensors_temperatures().
77
+ # Slow, see <https://github.com/giampaolo/psutil/issues/2082>.
78
+ try:
79
+ temps = psutil.sensors_temperatures()
80
+ except AttributeError:
81
+ return None
82
+ else:
83
+ # coretemp: intel, k10temp: amd
84
+ # <https://github.com/nschloe/tiptop/issues/37>
85
+ for key in ["coretemp", "k10temp"]:
86
+ if key not in temps:
87
+ continue
88
+ return [t.current for t in temps[key]]
89
+
90
+ return None
91
+
92
+
93
+ def get_current_freq():
94
+ # psutil.cpu_freq() is slow, so first try something else:
95
+ # <https://github.com/nschloe/tiptop/issues/37>
96
+ candidates = [
97
+ "/sys/devices/system/cpu/cpufreq/policy0/scaling_cur_freq",
98
+ "/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq",
99
+ ]
100
+ for candidate in candidates:
101
+ if Path(candidate).exists():
102
+ with open(candidate) as f:
103
+ content = f.read()
104
+ return int(content) / 1000
105
+
106
+ try:
107
+ cpu_freq = psutil.cpu_freq().current
108
+ except Exception:
109
+ # https://github.com/nschloe/tiptop/issues/25#issuecomment-1061390966
110
+ return None
111
+
112
+ if psutil.__version__ == "5.9.0" and cpu_freq < 10:
113
+ # Work around <https://github.com/giampaolo/psutil/issues/2049>
114
+ cpu_freq *= 1000
115
+ return cpu_freq
116
+
117
+
118
+ class CPU(Widget):
119
+ def on_mount(self):
120
+ self.width = 0
121
+ self.height = 0
122
+
123
+ # self.max_graph_width = 200
124
+
125
+ self.num_cores = psutil.cpu_count(logical=False)
126
+ num_threads = psutil.cpu_count(logical=True)
127
+
128
+ # 8 threads, 4 cores -> [[0, 4], [1, 5], [2, 6], [3, 7]]
129
+ assert num_threads % self.num_cores == 0
130
+ self.core_threads = transpose(list(chunks(range(num_threads), self.num_cores)))
131
+
132
+ self.cpu_total_stream = BrailleStream(50, 7, 0.0, 100.0)
133
+
134
+ self.thread_load_streams = [
135
+ BrailleStream(10, 1, 0.0, 100.0)
136
+ for _ in range(num_threads)
137
+ # BlockCharStream(10, 1, 0.0, 100.0) for _ in range(num_threads)
138
+ ]
139
+
140
+ temps = get_current_temps()
141
+
142
+ if temps is None:
143
+ self.has_cpu_temp = False
144
+ self.has_core_temps = False
145
+ else:
146
+ self.has_cpu_temp = len(temps) > 0
147
+ self.has_core_temps = len(temps) == 1 + self.num_cores
148
+
149
+ temp_low = 30.0
150
+ # TODO read from file
151
+ temp_high = 100.0
152
+
153
+ if self.has_cpu_temp:
154
+ self.temp_total_stream = BrailleStream(
155
+ 50, 7, temp_low, temp_high, flipud=True
156
+ )
157
+
158
+ if self.has_core_temps:
159
+ self.core_temp_streams = [
160
+ BrailleStream(5, 1, temp_low, temp_high)
161
+ for _ in range(self.num_cores)
162
+ ]
163
+
164
+ self.has_fan_rpm = False
165
+ try:
166
+ fan_current = list(psutil.sensors_fans().values())[0][0].current
167
+ except (AttributeError, IndexError):
168
+ pass
169
+ else:
170
+ self.has_fan_rpm = True
171
+ fan_low = 0
172
+ # Sometimes, psutil/computers will incorrectly report a fan
173
+ # speed of 2 ** 16 - 1; dismiss that.
174
+ if fan_current == 65535:
175
+ fan_current = 1
176
+ fan_high = max(fan_current, 1)
177
+ self.fan_stream = BrailleStream(50, 1, fan_low, fan_high)
178
+
179
+ box_title = ", ".join(
180
+ [
181
+ f"{self.num_cores} core" + ("s" if self.num_cores > 1 else ""),
182
+ f"{num_threads} thread" + ("s" if num_threads > 1 else ""),
183
+ ]
184
+ )
185
+ self.info_box = Panel(
186
+ "",
187
+ title=box_title,
188
+ title_align="left",
189
+ subtitle=None,
190
+ subtitle_align="left",
191
+ border_style="bright_black",
192
+ box=box.SQUARE,
193
+ expand=False,
194
+ )
195
+
196
+ self.panel = Panel(
197
+ "",
198
+ title=f"[b]CPU[/] - {get_cpu_model()}",
199
+ title_align="left",
200
+ border_style="bright_black",
201
+ box=box.SQUARE,
202
+ )
203
+
204
+ # immediately collect data to refresh info_box_width
205
+ self.collect_data()
206
+ self.set_interval(2.0, self.collect_data)
207
+
208
+ def collect_data(self):
209
+ # CPU loads
210
+ self.cpu_total_stream.add_value(psutil.cpu_percent())
211
+ #
212
+ load_per_thread = psutil.cpu_percent(percpu=True)
213
+ assert isinstance(load_per_thread, list)
214
+ for stream, load in zip(self.thread_load_streams, load_per_thread):
215
+ stream.add_value(load)
216
+
217
+ # CPU temperatures
218
+ if self.has_cpu_temp or self.has_core_temps:
219
+ temps = get_current_temps()
220
+ assert temps is not None
221
+
222
+ if self.has_cpu_temp:
223
+ self.temp_total_stream.add_value(temps[0])
224
+
225
+ if self.has_core_temps:
226
+ for stream, temp in zip(self.core_temp_streams, temps[1:]):
227
+ stream.add_value(temp)
228
+
229
+ lines_cpu = self.cpu_total_stream.graph
230
+ current_val_string = f"{self.cpu_total_stream.values[-1]:5.1f}%"
231
+ lines0 = lines_cpu[0][: -len(current_val_string)] + current_val_string
232
+ lines_cpu = [lines0] + lines_cpu[1:]
233
+ #
234
+ cpu_total_graph = "[yellow]" + "\n".join(lines_cpu) + "[/]\n"
235
+ #
236
+ if self.has_cpu_temp:
237
+ lines_temp = self.temp_total_stream.graph
238
+ current_val_string = f"{round(self.temp_total_stream.values[-1]):3d}°C"
239
+ lines0 = lines_temp[-1][: -len(current_val_string)] + current_val_string
240
+ lines_temp = lines_temp[:-1] + [lines0]
241
+ cpu_total_graph += "[slate_blue1]" + "\n".join(lines_temp) + "[/]"
242
+
243
+ # construct right info box
244
+ self._refresh_info_box(load_per_thread)
245
+
246
+ t = Table(expand=True, show_header=False, padding=0, box=None)
247
+ # Add ratio 1 to expand that column as much as possible
248
+ t.add_column("graph", no_wrap=True, ratio=1)
249
+ t.add_column("box", no_wrap=True, justify="left", vertical="middle")
250
+ t.add_row(cpu_total_graph, self.info_box)
251
+
252
+ if self.has_fan_rpm:
253
+ fan_current = list(psutil.sensors_fans().values())[0][0].current
254
+
255
+ # See comment above
256
+ if fan_current == 65535:
257
+ fan_current = self.fan_stream.maxval
258
+
259
+ if fan_current > self.fan_stream.maxval:
260
+ # TODO There should be a method that also rewrites all previous values
261
+ self.fan_stream.maxval = fan_current
262
+
263
+ self.fan_stream.add_value(fan_current)
264
+ string = f" {fan_current}rpm"
265
+ graph = Text(
266
+ self.fan_stream.graph[-1][: -len(string)] + string,
267
+ style="dark_orange",
268
+ )
269
+ t.add_row(graph, "")
270
+
271
+ self.panel.renderable = t
272
+
273
+ # textual method
274
+ self.refresh()
275
+
276
+ def _refresh_info_box(self, load_per_thread):
277
+ lines = []
278
+ for core_id, thread_ids in enumerate(self.core_threads):
279
+ line = []
280
+ for i in thread_ids:
281
+ color = val_to_color(load_per_thread[i], 0.0, 100.0)
282
+ line.append(
283
+ f"[{color}]"
284
+ + f"{self.thread_load_streams[i].graph[0]}"
285
+ + f"{round(self.thread_load_streams[i].values[-1]):3d}%"
286
+ + "[/]"
287
+ )
288
+ if self.has_core_temps:
289
+ stream = self.core_temp_streams[core_id]
290
+ val = stream.values[-1]
291
+ color = "slate_blue1" if val < 70.0 else "red3"
292
+ line.append(
293
+ f"[{color}]{stream.graph[0]} {round(stream.values[-1])}°C[/]"
294
+ )
295
+
296
+ lines.append(" ".join(line))
297
+
298
+ self.info_box.renderable = "\n".join(lines)
299
+
300
+ cpu_freq = get_current_freq()
301
+
302
+ if cpu_freq is None:
303
+ # https://github.com/nschloe/tiptop/issues/25
304
+ self.info_box.subtitle = None
305
+ else:
306
+ self.info_box.subtitle = f"{round(cpu_freq):4d} MHz"
307
+
308
+ # https://github.com/willmcgugan/rich/discussions/1559#discussioncomment-1459008
309
+ self.info_box_width = 4 + len(Text.from_markup(lines[0]))
310
+
311
+ def render(self):
312
+ return self.panel
313
+
314
+ async def on_resize(self, event):
315
+ self.width = event.width
316
+ self.height = event.height
317
+
318
+ # reset graph widths
319
+ graph_width = self.width - self.info_box_width - 5
320
+ self.cpu_total_stream.reset_width(graph_width)
321
+ if self.has_cpu_temp:
322
+ self.temp_total_stream.reset_width(graph_width)
323
+ if self.has_fan_rpm:
324
+ self.fan_stream.reset_width(graph_width)
325
+
326
+ # reset graph heights
327
+ # subtract border
328
+ total_height = self.height - 2
329
+ if self.has_cpu_temp:
330
+ # cpu total stream height: divide by two and round _down_
331
+ self.cpu_total_stream.reset_height(total_height // 2)
332
+ #
333
+ # temp total stream height: divide by two and round _up_. Subtract
334
+ # one if a fan stream is present.
335
+ temp_stream_height = -((-total_height) // 2)
336
+ if self.has_fan_rpm:
337
+ temp_stream_height -= 1
338
+
339
+ self.temp_total_stream.reset_height(temp_stream_height)
340
+ else:
341
+ # full size cpu stream
342
+ cpu_stream_height = total_height
343
+ if self.has_fan_rpm:
344
+ cpu_stream_height -= 1
345
+
346
+ self.cpu_total_stream.reset_height(cpu_stream_height)