netshow 0.1.1__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.
- netshow/__init__.py +3 -0
- netshow/app.py +390 -0
- netshow/cli.py +17 -0
- netshow/helpers.py +127 -0
- netshow/styles.py +234 -0
- netshow-0.1.1.dist-info/METADATA +152 -0
- netshow-0.1.1.dist-info/RECORD +10 -0
- netshow-0.1.1.dist-info/WHEEL +4 -0
- netshow-0.1.1.dist-info/entry_points.txt +2 -0
- netshow-0.1.1.dist-info/licenses/LICENSE +22 -0
netshow/__init__.py
ADDED
netshow/app.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import TypedDict
|
|
4
|
+
|
|
5
|
+
import psutil
|
|
6
|
+
from textual import events
|
|
7
|
+
from textual.app import App, ComposeResult
|
|
8
|
+
from textual.containers import (
|
|
9
|
+
Container,
|
|
10
|
+
Horizontal,
|
|
11
|
+
ScrollableContainer,
|
|
12
|
+
Vertical,
|
|
13
|
+
)
|
|
14
|
+
from textual.reactive import reactive
|
|
15
|
+
from textual.screen import Screen
|
|
16
|
+
from textual.timer import Timer
|
|
17
|
+
from textual.widgets import (
|
|
18
|
+
Button,
|
|
19
|
+
DataTable,
|
|
20
|
+
Footer,
|
|
21
|
+
Header,
|
|
22
|
+
Static,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from .helpers import get_lsof_conns, get_psutil_conns
|
|
26
|
+
from .styles import CSS
|
|
27
|
+
|
|
28
|
+
# Constants
|
|
29
|
+
REFRESH_INTERVAL = 3.0 # seconds
|
|
30
|
+
CONNECTION_COLUMNS = ["pid", "friendly", "proc", "laddr", "raddr", "status"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ConnectionData(TypedDict):
|
|
34
|
+
"""Type definition for connection data."""
|
|
35
|
+
|
|
36
|
+
pid: str
|
|
37
|
+
friendly: str
|
|
38
|
+
proc: str
|
|
39
|
+
laddr: str
|
|
40
|
+
raddr: str
|
|
41
|
+
status: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConnectionDetailScreen(Screen):
|
|
45
|
+
"""Screen for displaying detailed information about a selected connection."""
|
|
46
|
+
|
|
47
|
+
BINDINGS = [("escape", "app.pop_screen", "Back to connections")]
|
|
48
|
+
|
|
49
|
+
def __init__(self, connection_data: ConnectionData):
|
|
50
|
+
super().__init__()
|
|
51
|
+
self.connection_data = connection_data
|
|
52
|
+
self.process_info = self._get_process_info(connection_data["pid"])
|
|
53
|
+
|
|
54
|
+
def _get_status_icon(self, status: str) -> str:
|
|
55
|
+
"""Get an appropriate icon for connection status."""
|
|
56
|
+
status_icons = {
|
|
57
|
+
"ESTABLISHED": "β
",
|
|
58
|
+
"LISTEN": "π",
|
|
59
|
+
"TIME_WAIT": "β³",
|
|
60
|
+
"CLOSE_WAIT": "βΈοΈ",
|
|
61
|
+
"SYN_SENT": "π€",
|
|
62
|
+
"SYN_RECV": "π₯",
|
|
63
|
+
"FIN_WAIT1": "π",
|
|
64
|
+
"FIN_WAIT2": "π",
|
|
65
|
+
"CLOSING": "π",
|
|
66
|
+
"LAST_ACK": "π",
|
|
67
|
+
}
|
|
68
|
+
return status_icons.get(status, "β")
|
|
69
|
+
|
|
70
|
+
def _get_process_info(self, pid_str: str) -> dict:
|
|
71
|
+
"""Get additional process information if PID is available."""
|
|
72
|
+
if pid_str == "-":
|
|
73
|
+
return {}
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
pid = int(pid_str)
|
|
77
|
+
proc = psutil.Process(pid)
|
|
78
|
+
return {
|
|
79
|
+
"name": proc.name(),
|
|
80
|
+
"exe": proc.exe(),
|
|
81
|
+
"cmd": " ".join(proc.cmdline()),
|
|
82
|
+
"create_time": proc.create_time(),
|
|
83
|
+
"status": proc.status(),
|
|
84
|
+
"username": proc.username(),
|
|
85
|
+
"cwd": proc.cwd(),
|
|
86
|
+
"num_threads": proc.num_threads(),
|
|
87
|
+
"cpu_percent": proc.cpu_percent(interval=0.1),
|
|
88
|
+
"memory_percent": proc.memory_percent(),
|
|
89
|
+
"open_files": proc.open_files(),
|
|
90
|
+
"connections": proc.connections(),
|
|
91
|
+
}
|
|
92
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, ValueError):
|
|
93
|
+
return {}
|
|
94
|
+
|
|
95
|
+
def compose(self) -> ComposeResult:
|
|
96
|
+
"""Compose the detail screen layout."""
|
|
97
|
+
yield Header(show_clock=True)
|
|
98
|
+
|
|
99
|
+
with ScrollableContainer():
|
|
100
|
+
yield Static(
|
|
101
|
+
f"π Connection Details: {self.connection_data['friendly']}",
|
|
102
|
+
id="detail_title",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
with Horizontal(id="main_content"):
|
|
106
|
+
with Container(id="connection_details"):
|
|
107
|
+
yield Static("π Connection Info", classes="detail_title")
|
|
108
|
+
yield Static(
|
|
109
|
+
f"π PID: {self.connection_data['pid']}", classes="detail_item"
|
|
110
|
+
)
|
|
111
|
+
yield Static(
|
|
112
|
+
f"βοΈ Process: {self.connection_data['proc']}",
|
|
113
|
+
classes="detail_item",
|
|
114
|
+
)
|
|
115
|
+
yield Static(
|
|
116
|
+
f"π·οΈ Friendly Name: {self.connection_data['friendly']}",
|
|
117
|
+
classes="detail_item",
|
|
118
|
+
)
|
|
119
|
+
yield Static(
|
|
120
|
+
f"π Local Address: {self.connection_data['laddr']}",
|
|
121
|
+
classes="detail_item",
|
|
122
|
+
markup=False,
|
|
123
|
+
)
|
|
124
|
+
yield Static(
|
|
125
|
+
f"π Remote Address: {self.connection_data['raddr']}",
|
|
126
|
+
classes="detail_item",
|
|
127
|
+
markup=False,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
status = self.connection_data["status"]
|
|
131
|
+
status_icon = self._get_status_icon(status)
|
|
132
|
+
yield Static(
|
|
133
|
+
f"{status_icon} Status: {status}",
|
|
134
|
+
classes=f"detail_item status-{status}",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Show additional process info if available
|
|
138
|
+
if self.process_info:
|
|
139
|
+
with Container(id="process_info"):
|
|
140
|
+
yield Static("π§ Process Details", classes="detail_title")
|
|
141
|
+
yield Static(
|
|
142
|
+
f"π Executable: {self.process_info.get('exe', 'N/A')}",
|
|
143
|
+
classes="detail_item",
|
|
144
|
+
)
|
|
145
|
+
yield Static(
|
|
146
|
+
f"π» Command Line: {self.process_info.get('cmd', 'N/A')}",
|
|
147
|
+
classes="detail_item",
|
|
148
|
+
)
|
|
149
|
+
yield Static(
|
|
150
|
+
f"π Status: {self.process_info.get('status', 'N/A')}",
|
|
151
|
+
classes="detail_item",
|
|
152
|
+
)
|
|
153
|
+
yield Static(
|
|
154
|
+
f"π€ User: {self.process_info.get('username', 'N/A')}",
|
|
155
|
+
classes="detail_item",
|
|
156
|
+
)
|
|
157
|
+
yield Static(
|
|
158
|
+
f"π Working Directory: {self.process_info.get('cwd', 'N/A')}",
|
|
159
|
+
classes="detail_item",
|
|
160
|
+
)
|
|
161
|
+
yield Static(
|
|
162
|
+
f"π§΅ Threads: {self.process_info.get('num_threads', 'N/A')}",
|
|
163
|
+
classes="detail_item",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
cpu_percent = self.process_info.get("cpu_percent", 0.0)
|
|
167
|
+
cpu_icon = (
|
|
168
|
+
"π₯"
|
|
169
|
+
if cpu_percent > 50
|
|
170
|
+
else "β‘" if cpu_percent > 10 else "π€"
|
|
171
|
+
)
|
|
172
|
+
yield Static(
|
|
173
|
+
f"{cpu_icon} CPU Usage: {cpu_percent:.1f}%",
|
|
174
|
+
classes="detail_item",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
memory_percent = self.process_info.get("memory_percent", 0.0)
|
|
178
|
+
memory_display = (
|
|
179
|
+
f"{memory_percent:.2f}%"
|
|
180
|
+
if isinstance(memory_percent, (int, float))
|
|
181
|
+
else "N/A"
|
|
182
|
+
)
|
|
183
|
+
memory_icon = (
|
|
184
|
+
"π¨"
|
|
185
|
+
if memory_percent > 80
|
|
186
|
+
else "β οΈ" if memory_percent > 50 else "πΎ"
|
|
187
|
+
)
|
|
188
|
+
yield Static(
|
|
189
|
+
f"{memory_icon} Memory Usage: {memory_display}",
|
|
190
|
+
classes="detail_item",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Network connections from this process
|
|
194
|
+
connections = self.process_info.get("connections", [])
|
|
195
|
+
if connections:
|
|
196
|
+
conn_count = (
|
|
197
|
+
len(connections) if isinstance(connections, list) else 0
|
|
198
|
+
)
|
|
199
|
+
yield Static(
|
|
200
|
+
f"π Active Connections: {conn_count}",
|
|
201
|
+
classes="detail_item",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
with Container(id="button_container"):
|
|
205
|
+
yield Button("π Back to Connections", id="back_button")
|
|
206
|
+
|
|
207
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
208
|
+
"""Handle button press events."""
|
|
209
|
+
if event.button.id == "back_button":
|
|
210
|
+
self.app.pop_screen()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class NetshowApp(App):
|
|
214
|
+
"""A modern realβtime network connection monitor with enhanced visuals.
|
|
215
|
+
|
|
216
|
+
Features:
|
|
217
|
+
β’ **Beautiful gradient UI** with glass morphism effects
|
|
218
|
+
β’ **Animated status indicators** and visual feedback
|
|
219
|
+
β’ **Enhanced typography** with semantic icons
|
|
220
|
+
β’ **Preserves scroll position** when the table refreshes
|
|
221
|
+
β’ **Process-aware monitoring** with detailed drill-down views
|
|
222
|
+
β’ **Responsive design** that adapts to terminal size
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
CSS = CSS
|
|
226
|
+
|
|
227
|
+
total_connections = reactive(0)
|
|
228
|
+
active_connections = reactive(0)
|
|
229
|
+
listening_connections = reactive(0)
|
|
230
|
+
|
|
231
|
+
def compose(self) -> ComposeResult:
|
|
232
|
+
yield Header(show_clock=True)
|
|
233
|
+
with Vertical():
|
|
234
|
+
with Container(id="stats_container"):
|
|
235
|
+
yield Static("π Initializing NetShowβ¦", id="status_bar")
|
|
236
|
+
yield DataTable(id="connections_table")
|
|
237
|
+
yield Footer()
|
|
238
|
+
|
|
239
|
+
def on_mount(self) -> None:
|
|
240
|
+
table = self.query_one("#connections_table", DataTable)
|
|
241
|
+
table.add_columns(
|
|
242
|
+
"π PID",
|
|
243
|
+
"π Service",
|
|
244
|
+
"βοΈ Process",
|
|
245
|
+
"π Local Address",
|
|
246
|
+
"π Remote Address",
|
|
247
|
+
"π Status",
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Enable cursor to allow row selection
|
|
251
|
+
table.cursor_type = "row"
|
|
252
|
+
table.can_focus = True
|
|
253
|
+
|
|
254
|
+
# Refresh at regular intervals
|
|
255
|
+
self.timer: Timer = self.set_interval(
|
|
256
|
+
REFRESH_INTERVAL, self.refresh_connections
|
|
257
|
+
)
|
|
258
|
+
self.refresh_connections()
|
|
259
|
+
|
|
260
|
+
def refresh_connections(self) -> None:
|
|
261
|
+
table = self.query_one("#connections_table", DataTable)
|
|
262
|
+
|
|
263
|
+
# Capture current scroll offset & cursor row
|
|
264
|
+
row_offset, col_offset = getattr(table, "scroll_offset", (0, 0))
|
|
265
|
+
cursor_row = getattr(table, "cursor_row", 0)
|
|
266
|
+
|
|
267
|
+
table.clear()
|
|
268
|
+
|
|
269
|
+
status_bar = self.query_one("#status_bar", Static)
|
|
270
|
+
using_root = os.geteuid() == 0
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
conns = get_psutil_conns() if using_root else get_lsof_conns()
|
|
274
|
+
except (psutil.AccessDenied, PermissionError):
|
|
275
|
+
conns = get_lsof_conns()
|
|
276
|
+
using_root = False
|
|
277
|
+
|
|
278
|
+
# Count connection types for stats
|
|
279
|
+
established = listening = 0
|
|
280
|
+
for c in conns:
|
|
281
|
+
status = c["status"]
|
|
282
|
+
if status == "ESTABLISHED":
|
|
283
|
+
established += 1
|
|
284
|
+
elif status == "LISTEN":
|
|
285
|
+
listening += 1
|
|
286
|
+
|
|
287
|
+
# Add status icon to status column
|
|
288
|
+
status_icon = self._get_status_icon(status)
|
|
289
|
+
table.add_row(
|
|
290
|
+
c["pid"],
|
|
291
|
+
c["friendly"],
|
|
292
|
+
c["proc"],
|
|
293
|
+
c["laddr"],
|
|
294
|
+
c["raddr"],
|
|
295
|
+
f"{status_icon} {status}",
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# Update reactive stats
|
|
299
|
+
self.total_connections = len(conns)
|
|
300
|
+
self.active_connections = established
|
|
301
|
+
self.listening_connections = listening
|
|
302
|
+
|
|
303
|
+
# Restore scroll & cursor
|
|
304
|
+
if hasattr(table, "scroll_to"):
|
|
305
|
+
table.scroll_to(row_offset, col_offset)
|
|
306
|
+
if cursor_row < table.row_count and hasattr(table, "cursor_coordinate"):
|
|
307
|
+
table.cursor_coordinate = (cursor_row, 0) # type: ignore
|
|
308
|
+
|
|
309
|
+
source = "π psutil (root)" if using_root else "π§ lsof"
|
|
310
|
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
311
|
+
status_bar.update(
|
|
312
|
+
f"π Total: {len(conns)} | β
Active: {established} | π Listening: {listening} | "
|
|
313
|
+
f"{source} | π {timestamp}"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def _get_status_icon(self, status: str) -> str:
|
|
317
|
+
"""Get an appropriate icon for connection status."""
|
|
318
|
+
status_icons = {
|
|
319
|
+
"ESTABLISHED": "β
",
|
|
320
|
+
"LISTEN": "π",
|
|
321
|
+
"TIME_WAIT": "β³",
|
|
322
|
+
"CLOSE_WAIT": "βΈοΈ",
|
|
323
|
+
"SYN_SENT": "π€",
|
|
324
|
+
"SYN_RECV": "π₯",
|
|
325
|
+
"FIN_WAIT1": "π",
|
|
326
|
+
"FIN_WAIT2": "π",
|
|
327
|
+
"CLOSING": "π",
|
|
328
|
+
"LAST_ACK": "π",
|
|
329
|
+
}
|
|
330
|
+
return status_icons.get(status, "β")
|
|
331
|
+
|
|
332
|
+
def _get_selected_connection_data(self, row_data: tuple) -> ConnectionData:
|
|
333
|
+
"""Convert row data tuple to ConnectionData dict."""
|
|
334
|
+
# Extract status without icon (remove first 2 characters: icon + space)
|
|
335
|
+
status_with_icon = row_data[5]
|
|
336
|
+
clean_status = (
|
|
337
|
+
status_with_icon[2:] if len(status_with_icon) > 2 else status_with_icon
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
return ConnectionData(
|
|
341
|
+
pid=row_data[0],
|
|
342
|
+
friendly=row_data[1],
|
|
343
|
+
proc=row_data[2],
|
|
344
|
+
laddr=row_data[3],
|
|
345
|
+
raddr=row_data[4],
|
|
346
|
+
status=clean_status,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
async def on_key(self, event: events.Key) -> None:
|
|
350
|
+
if event.key == "q":
|
|
351
|
+
await self.action_quit()
|
|
352
|
+
elif event.key == "enter":
|
|
353
|
+
# When Enter is pressed on a highlighted row
|
|
354
|
+
table = self.query_one("#connections_table", DataTable)
|
|
355
|
+
if table.cursor_row is not None and table.cursor_row < table.row_count:
|
|
356
|
+
# Pause refreshing while viewing details
|
|
357
|
+
self.timer.pause()
|
|
358
|
+
|
|
359
|
+
# Get the row data at cursor position and use it directly
|
|
360
|
+
row_data = table.get_row_at(table.cursor_row)
|
|
361
|
+
selected_data = self._get_selected_connection_data(tuple(row_data))
|
|
362
|
+
await self.push_screen(ConnectionDetailScreen(selected_data))
|
|
363
|
+
|
|
364
|
+
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
365
|
+
"""Handle row highlighting in the DataTable."""
|
|
366
|
+
# This event fires when cursor moves over rows
|
|
367
|
+
pass
|
|
368
|
+
|
|
369
|
+
async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
370
|
+
"""Handle row selection in the DataTable."""
|
|
371
|
+
# Pause refreshing while viewing details
|
|
372
|
+
self.timer.pause()
|
|
373
|
+
|
|
374
|
+
# Get the selected row's data
|
|
375
|
+
table = self.query_one("#connections_table", DataTable)
|
|
376
|
+
row_data = table.get_row(event.row_key)
|
|
377
|
+
selected_data = self._get_selected_connection_data(tuple(row_data))
|
|
378
|
+
|
|
379
|
+
# Push the detail screen
|
|
380
|
+
await self.push_screen(ConnectionDetailScreen(selected_data))
|
|
381
|
+
|
|
382
|
+
async def on_screen_resume(self) -> None:
|
|
383
|
+
"""Called when this screen is resumed (after popping another screen)."""
|
|
384
|
+
# Resume refreshing when returning from detail view
|
|
385
|
+
self.timer.resume()
|
|
386
|
+
self.refresh_connections()
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
if __name__ == "__main__":
|
|
390
|
+
NetshowApp().run()
|
netshow/cli.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""CLI entry point for NetShow."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from .app import NetshowApp
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
"""Main CLI entry point."""
|
|
10
|
+
try:
|
|
11
|
+
NetshowApp().run()
|
|
12
|
+
except KeyboardInterrupt:
|
|
13
|
+
sys.exit(0)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
main()
|
netshow/helpers.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Helper functions for gathering network connections."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import psutil
|
|
9
|
+
|
|
10
|
+
# Friendly-name helpers
|
|
11
|
+
STATIC_MAP = {
|
|
12
|
+
"rapportd": "Handoff Sync Process",
|
|
13
|
+
"IPNExtension": "Tailscale",
|
|
14
|
+
"Code\x20H": "VSCode",
|
|
15
|
+
"Adobe\x20H": "Adobe",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
DOCKER_RE = re.compile(r"com\.docker", re.I)
|
|
19
|
+
PLEX_RE = re.compile(r"plex", re.I)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@lru_cache(maxsize=1)
|
|
23
|
+
def _docker_container_lookup() -> dict[str, str]:
|
|
24
|
+
"""Look up Docker container IDs and names."""
|
|
25
|
+
try:
|
|
26
|
+
out = subprocess.check_output(
|
|
27
|
+
["docker", "ps", "--format", "{{.ID}} {{.Names}}"],
|
|
28
|
+
text=True,
|
|
29
|
+
stderr=subprocess.DEVNULL,
|
|
30
|
+
)
|
|
31
|
+
except Exception:
|
|
32
|
+
return {}
|
|
33
|
+
return dict(line.split(maxsplit=1) for line in out.splitlines())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_friendly_name(proc_name: str, pid: int, cmdline: Optional[str]) -> str:
|
|
37
|
+
"""Get a friendly name for a process."""
|
|
38
|
+
if proc_name in STATIC_MAP:
|
|
39
|
+
return STATIC_MAP[proc_name]
|
|
40
|
+
if PLEX_RE.match(proc_name):
|
|
41
|
+
return "Plex Media Server"
|
|
42
|
+
if DOCKER_RE.match(proc_name):
|
|
43
|
+
cnt = len(_docker_container_lookup())
|
|
44
|
+
return f"Docker Desktop ({cnt} containers)" if cnt else "Docker Desktop"
|
|
45
|
+
if cmdline:
|
|
46
|
+
for cid, cname in _docker_container_lookup().items():
|
|
47
|
+
if cid in cmdline:
|
|
48
|
+
return f"Docker: {cname}"
|
|
49
|
+
return proc_name
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Connection gathering helpers
|
|
53
|
+
def get_psutil_conns() -> list[dict[str, str]]:
|
|
54
|
+
"""Get network connections using psutil."""
|
|
55
|
+
conns = []
|
|
56
|
+
for conn in psutil.net_connections(kind="tcp"):
|
|
57
|
+
pid = conn.pid if conn.pid else None
|
|
58
|
+
try:
|
|
59
|
+
proc = psutil.Process(pid) if pid else None
|
|
60
|
+
proc_name = proc.name() if proc else "-"
|
|
61
|
+
cmdline = " ".join(proc.cmdline()) if proc else ""
|
|
62
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
63
|
+
proc_name, cmdline = "-", ""
|
|
64
|
+
conns.append(
|
|
65
|
+
{
|
|
66
|
+
"pid": str(pid) if pid else "-",
|
|
67
|
+
"proc": proc_name,
|
|
68
|
+
"friendly": get_friendly_name(proc_name, pid or 0, cmdline),
|
|
69
|
+
"laddr": (
|
|
70
|
+
f"[{conn.laddr.ip}]:{conn.laddr.port}"
|
|
71
|
+
if conn.laddr and ":" in conn.laddr.ip
|
|
72
|
+
else f"{conn.laddr.ip}:{conn.laddr.port}" if conn.laddr else ""
|
|
73
|
+
),
|
|
74
|
+
"raddr": (
|
|
75
|
+
f"[{conn.raddr.ip}]:{conn.raddr.port}"
|
|
76
|
+
if conn.raddr and ":" in conn.raddr.ip
|
|
77
|
+
else f"{conn.raddr.ip}:{conn.raddr.port}" if conn.raddr else ""
|
|
78
|
+
),
|
|
79
|
+
"status": conn.status,
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
return conns
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_lsof_conns() -> list[dict[str, str]]:
|
|
86
|
+
"""Get network connections using lsof."""
|
|
87
|
+
try:
|
|
88
|
+
output = subprocess.check_output(
|
|
89
|
+
["lsof", "-nP", "-iTCP", "-sTCP:ESTABLISHED,LISTEN"],
|
|
90
|
+
text=True,
|
|
91
|
+
stderr=subprocess.DEVNULL,
|
|
92
|
+
)
|
|
93
|
+
except FileNotFoundError:
|
|
94
|
+
return []
|
|
95
|
+
|
|
96
|
+
conns = []
|
|
97
|
+
pattern = re.compile(
|
|
98
|
+
r"^(?P<proc>\S+)\s+(?P<pid>\d+)\s+\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+TCP\s+(?P<addr>.+)$"
|
|
99
|
+
)
|
|
100
|
+
for line in output.splitlines()[1:]:
|
|
101
|
+
m = pattern.match(line)
|
|
102
|
+
if not m:
|
|
103
|
+
continue
|
|
104
|
+
proc_name = m["proc"]
|
|
105
|
+
pid = int(m["pid"])
|
|
106
|
+
addr_field = m["addr"]
|
|
107
|
+
status = ""
|
|
108
|
+
if "(" in addr_field:
|
|
109
|
+
addr_field, status = addr_field.rsplit("(", 1)
|
|
110
|
+
status = status.rstrip(")")
|
|
111
|
+
laddr, raddr = (addr_field.split("->", 1) + [""])[:2]
|
|
112
|
+
laddr, raddr = laddr.strip(), raddr.strip()
|
|
113
|
+
try:
|
|
114
|
+
cmdline = " ".join(psutil.Process(pid).cmdline())
|
|
115
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
116
|
+
cmdline = ""
|
|
117
|
+
conns.append(
|
|
118
|
+
{
|
|
119
|
+
"pid": str(pid),
|
|
120
|
+
"proc": proc_name,
|
|
121
|
+
"friendly": get_friendly_name(proc_name, pid, cmdline),
|
|
122
|
+
"laddr": laddr,
|
|
123
|
+
"raddr": raddr,
|
|
124
|
+
"status": status,
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
return conns
|
netshow/styles.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Styles for the NetshowApp - Enhanced Selenized Dark Theme."""
|
|
2
|
+
|
|
3
|
+
CSS = """
|
|
4
|
+
/* === SELENIZED DARK COLOR PALETTE === */
|
|
5
|
+
$bg_0: #103c48;
|
|
6
|
+
$bg_1: #184956;
|
|
7
|
+
$bg_2: #2d5b69;
|
|
8
|
+
$dim_0: #72898f;
|
|
9
|
+
$fg_0: #adbcbc;
|
|
10
|
+
$fg_1: #cad8d9;
|
|
11
|
+
|
|
12
|
+
$red: #fa5750;
|
|
13
|
+
$green: #75b938;
|
|
14
|
+
$yellow: #dbb32d;
|
|
15
|
+
$blue: #4695f7;
|
|
16
|
+
$magenta: #f275be;
|
|
17
|
+
$cyan: #41c7b9;
|
|
18
|
+
$orange: #ed8649;
|
|
19
|
+
$violet: #af88eb;
|
|
20
|
+
|
|
21
|
+
$br_red: #ff665c;
|
|
22
|
+
$br_green: #84c747;
|
|
23
|
+
$br_yellow: #ebc13d;
|
|
24
|
+
$br_blue: #58a3ff;
|
|
25
|
+
$br_magenta: #ff84cd;
|
|
26
|
+
$br_cyan: #53d6c7;
|
|
27
|
+
$br_orange: #fd9456;
|
|
28
|
+
$br_violet: #bd96fa;
|
|
29
|
+
|
|
30
|
+
$accent_primary: $blue;
|
|
31
|
+
$accent_secondary: $magenta;
|
|
32
|
+
$accent_tertiary: $cyan;
|
|
33
|
+
$accent_success: $green;
|
|
34
|
+
$accent_warning: $yellow;
|
|
35
|
+
$accent_error: $red;
|
|
36
|
+
|
|
37
|
+
/* === GLOBAL STYLES === */
|
|
38
|
+
Screen {
|
|
39
|
+
background: $bg_0;
|
|
40
|
+
color: $fg_0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* === HEADER & FOOTER === */
|
|
44
|
+
Header {
|
|
45
|
+
background: $bg_1;
|
|
46
|
+
color: $fg_1;
|
|
47
|
+
border-bottom: solid $blue;
|
|
48
|
+
text-style: bold;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
Footer {
|
|
52
|
+
background: $bg_1;
|
|
53
|
+
color: $fg_1;
|
|
54
|
+
border-top: solid $blue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* === STATUS BAR === */
|
|
58
|
+
#stats_container {
|
|
59
|
+
height: auto;
|
|
60
|
+
margin: 1 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#status_bar {
|
|
64
|
+
background: $bg_2;
|
|
65
|
+
color: $fg_1;
|
|
66
|
+
height: 3;
|
|
67
|
+
padding: 0 2;
|
|
68
|
+
border: solid $dim_0;
|
|
69
|
+
margin: 0 1;
|
|
70
|
+
text-style: bold;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* === LAYOUT CONTAINERS === */
|
|
74
|
+
Vertical {
|
|
75
|
+
width: 100%;
|
|
76
|
+
height: 1fr;
|
|
77
|
+
padding: 0 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* === DATA TABLE STYLING === */
|
|
81
|
+
DataTable {
|
|
82
|
+
background: $bg_0;
|
|
83
|
+
color: $fg_0;
|
|
84
|
+
width: 100%;
|
|
85
|
+
height: 1fr;
|
|
86
|
+
border: solid $dim_0;
|
|
87
|
+
margin: 1 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
DataTable .header {
|
|
91
|
+
background: $bg_1;
|
|
92
|
+
color: $fg_1;
|
|
93
|
+
text-style: bold;
|
|
94
|
+
height: 3;
|
|
95
|
+
border-bottom: solid $blue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
DataTable .datatable--cursor {
|
|
99
|
+
background: $blue;
|
|
100
|
+
color: $bg_0;
|
|
101
|
+
text-style: bold;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
DataTable .datatable--hover {
|
|
105
|
+
background: $bg_2;
|
|
106
|
+
color: $fg_1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
DataTable:focus .datatable--cursor {
|
|
110
|
+
background: $br_blue;
|
|
111
|
+
text-style: bold;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* === DETAIL SCREEN STYLING === */
|
|
115
|
+
#detail_title {
|
|
116
|
+
background: $bg_2;
|
|
117
|
+
color: $fg_1;
|
|
118
|
+
height: 4;
|
|
119
|
+
padding: 1 2;
|
|
120
|
+
text-align: center;
|
|
121
|
+
text-style: bold;
|
|
122
|
+
margin: 1 0 2 0;
|
|
123
|
+
border: solid $blue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#main_content {
|
|
127
|
+
height: auto;
|
|
128
|
+
margin: 0 2;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#connection_details, #process_info {
|
|
132
|
+
background: $bg_1;
|
|
133
|
+
padding: 2;
|
|
134
|
+
margin: 0 1 2 0;
|
|
135
|
+
border: solid $dim_0;
|
|
136
|
+
height: auto;
|
|
137
|
+
width: 1fr;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.section_header {
|
|
141
|
+
background: $bg_2;
|
|
142
|
+
color: $fg_1;
|
|
143
|
+
padding: 1 2;
|
|
144
|
+
text-align: center;
|
|
145
|
+
text-style: bold;
|
|
146
|
+
margin: 0 0 1 0;
|
|
147
|
+
height: 3;
|
|
148
|
+
border: solid $cyan;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.detail_title {
|
|
152
|
+
margin: 0 0 1 0;
|
|
153
|
+
padding: 0 1;
|
|
154
|
+
color: $fg_1;
|
|
155
|
+
text-style: bold;
|
|
156
|
+
border-bottom: solid $cyan;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.detail_item {
|
|
160
|
+
margin: 0 0 1 0;
|
|
161
|
+
padding: 0 1;
|
|
162
|
+
color: $fg_0;
|
|
163
|
+
border-left: solid $cyan;
|
|
164
|
+
padding-left: 2;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.detail_item:hover {
|
|
168
|
+
color: $fg_1;
|
|
169
|
+
background: $bg_2;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* === BUTTONS === */
|
|
173
|
+
#button_container {
|
|
174
|
+
align: center middle;
|
|
175
|
+
height: auto;
|
|
176
|
+
margin: 2 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#back_button {
|
|
180
|
+
background: $blue;
|
|
181
|
+
color: $bg_0;
|
|
182
|
+
border: solid $blue;
|
|
183
|
+
width: 25;
|
|
184
|
+
height: 3;
|
|
185
|
+
text-style: bold;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
#back_button:hover {
|
|
189
|
+
background: $br_blue;
|
|
190
|
+
border: solid $br_blue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#back_button:focus {
|
|
194
|
+
background: $cyan;
|
|
195
|
+
border: solid $cyan;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
Button:focus {
|
|
199
|
+
border: solid $blue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* === SCROLLABLE CONTAINERS === */
|
|
203
|
+
ScrollableContainer {
|
|
204
|
+
background: transparent;
|
|
205
|
+
scrollbar-background: $bg_1;
|
|
206
|
+
scrollbar-color: $blue;
|
|
207
|
+
scrollbar-color-hover: $br_blue;
|
|
208
|
+
scrollbar-color-active: $cyan;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* === CONNECTION STATUS INDICATORS === */
|
|
212
|
+
.status-ESTABLISHED {
|
|
213
|
+
color: $green;
|
|
214
|
+
text-style: bold;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.status-LISTEN {
|
|
218
|
+
color: $blue;
|
|
219
|
+
text-style: bold;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.status-TIME_WAIT {
|
|
223
|
+
color: $yellow;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.status-CLOSE_WAIT {
|
|
227
|
+
color: $red;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* === ACCESSIBILITY === */
|
|
231
|
+
*:focus {
|
|
232
|
+
outline: solid $blue;
|
|
233
|
+
}
|
|
234
|
+
"""
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: netshow
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A real-time network connection monitor with friendly service names
|
|
5
|
+
Author: Taylor Wilsdon
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: connections,monitoring,network,tui
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: System Administrators
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: System :: Monitoring
|
|
21
|
+
Classifier: Topic :: System :: Networking :: Monitoring
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: psutil>=5.9.0
|
|
24
|
+
Requires-Dist: textual>=0.40.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: black>=23.0.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: mypy>=1.0.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: types-psutil>=5.9.0; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
<h1 align="center">π¦ NetShow</h1>
|
|
34
|
+
<p align="center"><em>Friendly, process-aware network monitoring for your terminal</em></p>
|
|
35
|
+
|
|
36
|
+
<p align="center">
|
|
37
|
+
<img src="https://img.shields.io/badge/python-3.9%2B-blue?logo=python&logoColor=white" alt="Python versions">
|
|
38
|
+
<img src="https://img.shields.io/github/license/taylorwilsdon/netshow?color=green" alt="License">
|
|
39
|
+
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey" alt="Platform">
|
|
40
|
+
<img src="https://img.shields.io/badge/code%20style-ruff-black?logo=ruff" alt="Code style: ruff">
|
|
41
|
+
<img src="https://img.shields.io/badge/UI-Textual-purple" alt="Built with Textual">
|
|
42
|
+
<img src="https://img.shields.io/badge/dependencies-uv-orange?logo=uv" alt="uv">
|
|
43
|
+
</p>
|
|
44
|
+
|
|
45
|
+
<div align="center">
|
|
46
|
+
<video width="1012px" src="https://github.com/user-attachments/assets/97f5af92-ec0a-4243-9894-d0c9d9470e1a"></video>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
<details>
|
|
52
|
+
<summary><strong>Table of Contents</strong></summary>
|
|
53
|
+
|
|
54
|
+
- [Features](#features)
|
|
55
|
+
- [Quickstart](#quickstart)
|
|
56
|
+
- [Usage](#usage)
|
|
57
|
+
- [Keybindings](#keybindings)
|
|
58
|
+
- [Development](#development)
|
|
59
|
+
- [Requirements](#requirements)
|
|
60
|
+
- [Contributing](#contributing)
|
|
61
|
+
- [License](#license)
|
|
62
|
+
</details>
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## β¨ Features
|
|
67
|
+
|
|
68
|
+
| Capability | Details |
|
|
69
|
+
|------------|---------|
|
|
70
|
+
| **Live TCP monitor** | Refreshes every 3 s (configurable) while preserving scroll position |
|
|
71
|
+
| **Human-friendly service names** | Shows *Docker*, *Plex*, *VS Code*, etc. instead of cryptic binaries |
|
|
72
|
+
| **Deep process drill-down** | Path, PID, cmdline, cwd, threads, CPU %, memory %, open files, active connections |
|
|
73
|
+
| **Clickable / keyboard navigation** | Press `β΅` or click a row for a dedicated detail screen; refresh pauses automatically |
|
|
74
|
+
| **Runs privileged <br>or unprivileged** | Uses `psutil` (root) for full fidelity, falls back to `lsof` if run as a regular user |
|
|
75
|
+
| **Modern Textual UI** | Smooth scrolling, dark theme, status bar with connection count & data source |
|
|
76
|
+
| **Zero-pain install** | Powered by [`uv`](https://github.com/astral-sh/uv) for lightning-fast dependency resolution |
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## π Quickstart
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
# Install (recommended)
|
|
84
|
+
uv pip install netshow
|
|
85
|
+
|
|
86
|
+
# Run
|
|
87
|
+
netshow
|
|
88
|
+
````
|
|
89
|
+
|
|
90
|
+
> **Tip:** Without root/sudo, NetShow silently switches to `lsof` and still gives you most connections.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## π οΈ Usage
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
netshow [--interval 1.0] [--no-colors]
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
| Option | Description | Default |
|
|
101
|
+
| ------------------ | -------------------- | ------- |
|
|
102
|
+
| `--interval <sec>` | Refresh rate (float) | `3.0` |
|
|
103
|
+
| `--no-colors` | Disable ANSI colors | Off |
|
|
104
|
+
|
|
105
|
+
### Keybindings
|
|
106
|
+
|
|
107
|
+
| Key / Mouse | Action |
|
|
108
|
+
| ----------- | ---------------- |
|
|
109
|
+
| β / β | Move cursor |
|
|
110
|
+
| β΅ / Click | Open detail view |
|
|
111
|
+
| Esc / β | Back to list |
|
|
112
|
+
| **q** | Quit NetShow |
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## π©βπ» Development
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
git clone https://github.com/taylorwilsdon/netshow.git
|
|
120
|
+
cd netshow
|
|
121
|
+
uv sync --extra dev
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Quality Gates
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
pytest # tests
|
|
128
|
+
ruff format . # auto-format
|
|
129
|
+
ruff check . # lint
|
|
130
|
+
mypy src/ # type check
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## π Requirements
|
|
136
|
+
|
|
137
|
+
* Python **β₯ 3.9**
|
|
138
|
+
* macOS or Linux
|
|
139
|
+
* `lsof` (usually pre-installed)
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## π€ Contributing
|
|
144
|
+
|
|
145
|
+
Pull requests and β stars are welcome! Found a bug or have a feature request? Please [open an issue](https://github.com/taylorwilsdon/netshow/issues).
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## π License
|
|
150
|
+
|
|
151
|
+
MIT β see [`LICENSE`](LICENSE) for full text.
|
|
152
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
netshow/__init__.py,sha256=Ii5MvKekxwbMOFspk84VayHSrNbacGOtSpEw8cUmMz8,107
|
|
2
|
+
netshow/app.py,sha256=jozqlrDRtuswkOi71zm51a2wySJylH4ZCmg6eRtucSQ,14494
|
|
3
|
+
netshow/cli.py,sha256=Gf2bP_jkcXHjG6TBnP-gd3t5EfzdAr_lgNahN6j60Ik,256
|
|
4
|
+
netshow/helpers.py,sha256=dkbxdtQnXGv8k7-J9c9ZtM4OFmYw9a91lL44g4xT4Vc,4139
|
|
5
|
+
netshow/styles.py,sha256=8ljAqE3UpWPdWzdwOTw8I18-UULsT6LOo7lMQwtFLSY,3786
|
|
6
|
+
netshow-0.1.1.dist-info/METADATA,sha256=NhIsGKeokJ6-4y_x-debaVboZOsCPa1VX5SUODFJzsc,4662
|
|
7
|
+
netshow-0.1.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
8
|
+
netshow-0.1.1.dist-info/entry_points.txt,sha256=UYD75nElh3HHMSzxCSG7q-BqrgsbELsTU6jrzD0yOw4,45
|
|
9
|
+
netshow-0.1.1.dist-info/licenses/LICENSE,sha256=hFwE7mP1CbnwuyD3PdviRJ5AmeIMnu5sCq52Two_Rnc,1120
|
|
10
|
+
netshow-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
(base) β reddacted git:(main) β cat LICENSE
|
|
2
|
+
MIT License
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2025 Taylor Wilsdon
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|