bwninja 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bwninja-1.0.0/PKG-INFO +33 -0
- bwninja-1.0.0/README.md +4 -0
- bwninja-1.0.0/bwninja/__init__.py +0 -0
- bwninja-1.0.0/bwninja/_version.py +1 -0
- bwninja-1.0.0/bwninja/net.py +74 -0
- bwninja-1.0.0/bwninja/tui.py +276 -0
- bwninja-1.0.0/bwninja.egg-info/PKG-INFO +33 -0
- bwninja-1.0.0/bwninja.egg-info/SOURCES.txt +12 -0
- bwninja-1.0.0/bwninja.egg-info/dependency_links.txt +1 -0
- bwninja-1.0.0/bwninja.egg-info/entry_points.txt +2 -0
- bwninja-1.0.0/bwninja.egg-info/requires.txt +3 -0
- bwninja-1.0.0/bwninja.egg-info/top_level.txt +1 -0
- bwninja-1.0.0/pyproject.toml +51 -0
- bwninja-1.0.0/setup.cfg +4 -0
bwninja-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: bwninja
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A modern replacement for iftop
|
|
5
|
+
Author-email: Tom Flanagan <tom@zkpq.ca>
|
|
6
|
+
Project-URL: Homepage, https://bwninja.zkpq.ca
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Environment :: Console :: Curses
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: Intended Audience :: Information Technology
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: Intended Audience :: Telecommunications Industry
|
|
15
|
+
Classifier: License :: Free For Educational Use
|
|
16
|
+
Classifier: License :: Free For Home Use
|
|
17
|
+
Classifier: License :: Free for non-commercial use
|
|
18
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python
|
|
21
|
+
Classifier: Topic :: Internet
|
|
22
|
+
Classifier: Topic :: System :: Monitoring
|
|
23
|
+
Classifier: Topic :: System :: Networking :: Monitoring
|
|
24
|
+
Classifier: Topic :: Utilities
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
Requires-Dist: blessed
|
|
27
|
+
Requires-Dist: bcc
|
|
28
|
+
Requires-Dist: pandas
|
|
29
|
+
|
|
30
|
+
# Bandwidth Ninja (bwninja)
|
|
31
|
+
|
|
32
|
+
A modern replacement for `iftop`
|
|
33
|
+
|
bwninja-1.0.0/README.md
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
version = __version__ = '1.0.0'
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import ipaddress
|
|
3
|
+
import logging
|
|
4
|
+
import pathlib
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from bcc import BPF
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
LOG = logging.getLogger('net.net')
|
|
11
|
+
|
|
12
|
+
import dataclasses
|
|
13
|
+
@dataclasses.dataclass
|
|
14
|
+
class Flow:
|
|
15
|
+
src: ipaddress.IPv4Address
|
|
16
|
+
dst: ipaddress.IPv4Address
|
|
17
|
+
# TODO:
|
|
18
|
+
# ipv6
|
|
19
|
+
# protocol
|
|
20
|
+
# srcport
|
|
21
|
+
# dstport
|
|
22
|
+
# num packets
|
|
23
|
+
bytes: int
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@functools.cache
|
|
27
|
+
def get_bpf():
|
|
28
|
+
src_path = str(pathlib.Path(__file__).parent/'net.c')
|
|
29
|
+
return BPF(src_file=src_path, debug=0)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def monitor(attachment, interface, printk=False):
|
|
33
|
+
b = get_bpf()
|
|
34
|
+
if attachment == 'socket':
|
|
35
|
+
fn=b.load_func("sock_peek_packet", BPF.SOCKET_FILTER)
|
|
36
|
+
BPF.attach_raw_socket(dev=interface, fn=fn)
|
|
37
|
+
elif attachment == 'xdp':
|
|
38
|
+
fn=b.load_func("xdp_peek_packet", BPF.XDP)
|
|
39
|
+
BPF.attach_xdp(dev=interface, fn=fn)
|
|
40
|
+
else:
|
|
41
|
+
# TODO: tc (traffic control)
|
|
42
|
+
raise ValueError(attachment)
|
|
43
|
+
|
|
44
|
+
flows_hash = b["flows"]
|
|
45
|
+
LOG.info('bpf ok')
|
|
46
|
+
if printk:
|
|
47
|
+
b.trace_print()
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
while 1:
|
|
51
|
+
flows = list(flows_hash.items_lookup_and_delete_batch())
|
|
52
|
+
LOG.debug(f'{len(flows)} flows recoded')
|
|
53
|
+
results = []
|
|
54
|
+
for flow, stat in flows:
|
|
55
|
+
a = ipaddress.ip_address(flow.ip_a)
|
|
56
|
+
b = ipaddress.ip_address(flow.ip_b)
|
|
57
|
+
f = Flow(b, a, stat)
|
|
58
|
+
results.append(f)
|
|
59
|
+
# debugging
|
|
60
|
+
LOG.debug(f'{a} -> {b}: {stat}')
|
|
61
|
+
yield results
|
|
62
|
+
|
|
63
|
+
except KeyboardInterrupt:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == '__main__':
|
|
68
|
+
logging.basicConfig(
|
|
69
|
+
level=logging.DEBUG,
|
|
70
|
+
format='%(asctime)s:%(levelname)s:%(name)s:%(funcName)s:%(lineno)d %(message)s')
|
|
71
|
+
|
|
72
|
+
for data in monitor(attachment='socket', interface='eth0'):
|
|
73
|
+
print(data)
|
|
74
|
+
time.sleep(1)
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import ipaddress
|
|
2
|
+
import argparse
|
|
3
|
+
import dataclasses
|
|
4
|
+
import logging
|
|
5
|
+
import socket
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import enum
|
|
9
|
+
|
|
10
|
+
import blessed
|
|
11
|
+
import pandas
|
|
12
|
+
|
|
13
|
+
from . import net
|
|
14
|
+
|
|
15
|
+
LOG = logging.getLogger('nettui')
|
|
16
|
+
|
|
17
|
+
# TODO print pretty bar graph
|
|
18
|
+
# TODO lan rdns somehow
|
|
19
|
+
# TODO process (pid) tracking
|
|
20
|
+
# TODO hsz() coloring
|
|
21
|
+
# TODO cursor
|
|
22
|
+
# TODO ipv6 support
|
|
23
|
+
# TODO tree of flows to expand host rollup to ports etc
|
|
24
|
+
# TODO root tree by pid, protocol, etc
|
|
25
|
+
# TODO mouse cursor
|
|
26
|
+
# TODO show extra data of selected
|
|
27
|
+
# TODO show time series graph of selected row
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_local_addresses():
|
|
31
|
+
import fcntl
|
|
32
|
+
import struct
|
|
33
|
+
addrs = {}
|
|
34
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
35
|
+
for i, name in socket.if_nameindex():
|
|
36
|
+
try:
|
|
37
|
+
ip = socket.inet_ntoa(fcntl.ioctl(
|
|
38
|
+
s.fileno(),
|
|
39
|
+
0x8915, # SIOCGIFADDR
|
|
40
|
+
struct.pack('256s', name[:15].encode('utf8'))
|
|
41
|
+
)[20:24])
|
|
42
|
+
addrs[ipaddress.IPv4Address(ip)] = name
|
|
43
|
+
except OSError:
|
|
44
|
+
LOG.warning(f'Could not resolve address for interface {name}')
|
|
45
|
+
return addrs
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DNSCache:
|
|
49
|
+
def __init__(self):
|
|
50
|
+
self.resolved = {}
|
|
51
|
+
self.pending = set()
|
|
52
|
+
self.event = threading.Event()
|
|
53
|
+
self.thread = threading.Thread(target=self.run)
|
|
54
|
+
self.thread.daemon = True
|
|
55
|
+
self.thread.start()
|
|
56
|
+
|
|
57
|
+
def run(self):
|
|
58
|
+
while 1:
|
|
59
|
+
self.event.wait(timeout=10)
|
|
60
|
+
self.event.clear()
|
|
61
|
+
for ip in list(self.pending):
|
|
62
|
+
try:
|
|
63
|
+
hostname, aliaslist, ipaddrlist = socket.gethostbyaddr(str(ip))
|
|
64
|
+
self.resolved[ip] = hostname
|
|
65
|
+
self.pending.remove(ip)
|
|
66
|
+
except socket.herror as e:
|
|
67
|
+
if e.errno == 1:
|
|
68
|
+
self.resolved[ip] = f'{ip} ?' # TODO use some coloring
|
|
69
|
+
self.pending.remove(ip)
|
|
70
|
+
|
|
71
|
+
def resolve(self, key):
|
|
72
|
+
try:
|
|
73
|
+
return self.resolved[key]
|
|
74
|
+
except KeyError: pass
|
|
75
|
+
|
|
76
|
+
if key in LOCAL:
|
|
77
|
+
s = f'[{HOST}.{LOCAL[key]}] {key}'
|
|
78
|
+
self.resolved[key] = s
|
|
79
|
+
return s
|
|
80
|
+
|
|
81
|
+
self.pending.add(key)
|
|
82
|
+
self.event.set()
|
|
83
|
+
|
|
84
|
+
return str(key) # TODO coloring
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
TERM = blessed.Terminal()
|
|
88
|
+
TERM.number_of_colors = 256
|
|
89
|
+
DNS = DNSCache()
|
|
90
|
+
HOST = socket.gethostname()
|
|
91
|
+
LOCAL = get_local_addresses()
|
|
92
|
+
|
|
93
|
+
class Address(ipaddress.IPv4Address):
|
|
94
|
+
# TODO IPv6
|
|
95
|
+
class Type(enum.IntEnum):
|
|
96
|
+
LOCAL = enum.auto()
|
|
97
|
+
UNKNOWN = enum.auto()
|
|
98
|
+
LINK_LOCAL = enum.auto()
|
|
99
|
+
LOOPBACK = enum.auto()
|
|
100
|
+
MULTICAST = enum.auto()
|
|
101
|
+
PRIVATE = enum.auto()
|
|
102
|
+
RESERVED = enum.auto()
|
|
103
|
+
UNSPECIFIED = enum.auto()
|
|
104
|
+
GLOBAL = enum.auto()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def type_key(self):
|
|
108
|
+
if self in LOCAL: return Address.Type.LOCAL
|
|
109
|
+
if self.is_global: return Address.Type.GLOBAL
|
|
110
|
+
if self.is_link_local: return Address.Type.LINK_LOCAL
|
|
111
|
+
if self.is_loopback: return Address.Type.LOOPBACK
|
|
112
|
+
if self.is_multicast: return Address.Type.MULTICAST
|
|
113
|
+
if self.is_private: return Address.Type.PRIVATE
|
|
114
|
+
if self.is_reserved: return Address.Type.RESERVED
|
|
115
|
+
if self.is_unspecified: return Address.Type.UNSPECIFIED
|
|
116
|
+
return Address.Type.UNKNOWN
|
|
117
|
+
|
|
118
|
+
def sort_key(self):
|
|
119
|
+
return self.type_key(), self
|
|
120
|
+
|
|
121
|
+
def pretty(self):
|
|
122
|
+
c = {
|
|
123
|
+
Address.Type.LOCAL: TERM.on_green,
|
|
124
|
+
Address.Type.PRIVATE: TERM.orange,
|
|
125
|
+
Address.Type.GLOBAL: TERM.cyan,
|
|
126
|
+
Address.Type.MULTICAST: TERM.red,
|
|
127
|
+
}.get(self.type_key(), TERM.grey)
|
|
128
|
+
s = DNS.resolve(self)
|
|
129
|
+
return c + s + TERM.normal
|
|
130
|
+
|
|
131
|
+
class Stats:
|
|
132
|
+
def __init__(self, ser):
|
|
133
|
+
self.ser = ser
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def duration(self):
|
|
137
|
+
ts = self.ser['timestamp']
|
|
138
|
+
return max(1, ts.max() - ts.min())
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def mean_bytes(self):
|
|
142
|
+
return self.ser['bytes'].sum() / self.duration
|
|
143
|
+
|
|
144
|
+
class NetTui:
|
|
145
|
+
def __init__(self, interval, interfaces, **kw):
|
|
146
|
+
self.interval = interval
|
|
147
|
+
self.dns = DNSCache()
|
|
148
|
+
if not interfaces:
|
|
149
|
+
interfaces = list(LOCAL.values())
|
|
150
|
+
self.mons = {dev: net.monitor(interface=dev, **kw) for dev in interfaces}
|
|
151
|
+
[next(m) for m in self.mons.values()]
|
|
152
|
+
self.df = pandas.DataFrame()
|
|
153
|
+
self.log = {}
|
|
154
|
+
self.history = 600
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def run(self):
|
|
158
|
+
# TODO reconfigure logging to not print to screen now
|
|
159
|
+
print("press 'q' to quit.")
|
|
160
|
+
with TERM.cbreak(), TERM.hidden_cursor(), TERM.fullscreen():
|
|
161
|
+
while 1:
|
|
162
|
+
self.update_flows()
|
|
163
|
+
print(TERM.home, end='')
|
|
164
|
+
# print(TERM.home + TERM.clear, end='')
|
|
165
|
+
|
|
166
|
+
self.draw_flows()
|
|
167
|
+
try: val = TERM.inkey(timeout=self.interval)
|
|
168
|
+
except KeyboardInterrupt: break
|
|
169
|
+
|
|
170
|
+
# timeout
|
|
171
|
+
if val == '': continue
|
|
172
|
+
|
|
173
|
+
if val.is_sequence:
|
|
174
|
+
print("got sequence: {0}.".format((str(val), val.name, val.code)))
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
print("got {0}.".format(val))
|
|
178
|
+
if val.lower() == 'q': break
|
|
179
|
+
print(f'bye!{TERM.normal}')
|
|
180
|
+
|
|
181
|
+
def update_flows(self):
|
|
182
|
+
# LOG.debug("update")
|
|
183
|
+
|
|
184
|
+
for name, mon in self.mons.items():
|
|
185
|
+
now = time.time()
|
|
186
|
+
rows = []
|
|
187
|
+
for flow in next(mon):
|
|
188
|
+
d = dataclasses.asdict(flow)
|
|
189
|
+
d['src'] = Address(d['src'].packed)
|
|
190
|
+
d['dst'] = Address(d['dst'].packed)
|
|
191
|
+
rows.append(d)
|
|
192
|
+
if rows:
|
|
193
|
+
df = pandas.DataFrame.from_records(rows, index=['src', 'dst'])
|
|
194
|
+
df['timestamp'] = now
|
|
195
|
+
self.log[now] = df
|
|
196
|
+
|
|
197
|
+
if self.log:
|
|
198
|
+
self.df = pandas.concat(self.log.values())
|
|
199
|
+
for ts in list(self.log.keys()):
|
|
200
|
+
if ts + self.history < now:
|
|
201
|
+
del self.log[ts]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def draw_flows(self):
|
|
205
|
+
now = time.time()
|
|
206
|
+
dur = 30
|
|
207
|
+
since = now - dur
|
|
208
|
+
|
|
209
|
+
if len(self.df) == 0: return
|
|
210
|
+
window = self.df.loc[self.df['timestamp'] > since]
|
|
211
|
+
if len(window) == 0: return
|
|
212
|
+
|
|
213
|
+
grouped = window.groupby(level=['src', 'dst'], observed=True)
|
|
214
|
+
pairs = set()
|
|
215
|
+
for pair in grouped.indices.keys():
|
|
216
|
+
key = tuple(sorted(pair, key=Address.sort_key))
|
|
217
|
+
pairs.add(key)
|
|
218
|
+
|
|
219
|
+
def sort_key(x):
|
|
220
|
+
a, b = x
|
|
221
|
+
return Address.sort_key(a) + Address.sort_key(b)
|
|
222
|
+
|
|
223
|
+
pairs = sorted(pairs, key=sort_key)
|
|
224
|
+
|
|
225
|
+
W = TERM.width
|
|
226
|
+
|
|
227
|
+
s = 0
|
|
228
|
+
e = len(pairs)
|
|
229
|
+
# print(TERM.move_xy(0, 1), end='')
|
|
230
|
+
print(f'{TERM.black_on_white} Network Flows past {dur}s {s+1}-{len(pairs)}{TERM.clear_eol}{TERM.normal}')
|
|
231
|
+
for i, (src, dst) in enumerate(pairs[s:e]):
|
|
232
|
+
try:
|
|
233
|
+
tx = grouped.get_group((src, dst))
|
|
234
|
+
tx_bytes = Stats(tx).mean_bytes
|
|
235
|
+
except KeyError:
|
|
236
|
+
tx_bytes = 0
|
|
237
|
+
try:
|
|
238
|
+
rx = grouped.get_group((dst, src))
|
|
239
|
+
rx_bytes = Stats(rx).mean_bytes
|
|
240
|
+
except KeyError:
|
|
241
|
+
rx_bytes = 0
|
|
242
|
+
|
|
243
|
+
P = 24
|
|
244
|
+
src_s = TERM.ljust(src.pretty(), W // 2 - P)
|
|
245
|
+
dst_s = TERM.rjust(dst.pretty(), W // 2 - P)
|
|
246
|
+
print(f'{TERM.dim}{i+s+1:3d}{TERM.normal} {src_s} {tx_bytes:10,.0f} <--> {rx_bytes:10,.0f} {dst_s}{TERM.clear_eol}')
|
|
247
|
+
print(f'{TERM.black_on_white}end{TERM.clear_eol}{TERM.normal}')
|
|
248
|
+
|
|
249
|
+
for w in range(e - s + 3, TERM.height):
|
|
250
|
+
print(TERM.normal + '>' + TERM.clear_eol)
|
|
251
|
+
|
|
252
|
+
# TODO debug logs
|
|
253
|
+
# print(TERM.clear)
|
|
254
|
+
# print(TERM.move_xy(0, 40))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def main():
|
|
258
|
+
parser = argparse.ArgumentParser(
|
|
259
|
+
formatter_class=argparse.RawTextHelpFormatter,
|
|
260
|
+
description=__doc__)
|
|
261
|
+
|
|
262
|
+
parser.add_argument('--interfaces', '-i', nargs='*', type=str, default=())
|
|
263
|
+
parser.add_argument('--interval', '-t', type=float, default=0.3)
|
|
264
|
+
parser.add_argument('--attachment', '-a', type=str, default='socket')
|
|
265
|
+
parser.add_argument('--printk', default=False, action='store_true')
|
|
266
|
+
|
|
267
|
+
args = parser.parse_args()
|
|
268
|
+
NetTui(**vars(args)).run()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
if __name__ == '__main__':
|
|
272
|
+
logging.basicConfig(
|
|
273
|
+
level=logging.INFO,
|
|
274
|
+
format='%(asctime)s:%(levelname)s:%(name)s:%(funcName)s:%(lineno)d %(message)s')
|
|
275
|
+
|
|
276
|
+
main()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: bwninja
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A modern replacement for iftop
|
|
5
|
+
Author-email: Tom Flanagan <tom@zkpq.ca>
|
|
6
|
+
Project-URL: Homepage, https://bwninja.zkpq.ca
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Environment :: Console :: Curses
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: Intended Audience :: Information Technology
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: Intended Audience :: Telecommunications Industry
|
|
15
|
+
Classifier: License :: Free For Educational Use
|
|
16
|
+
Classifier: License :: Free For Home Use
|
|
17
|
+
Classifier: License :: Free for non-commercial use
|
|
18
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python
|
|
21
|
+
Classifier: Topic :: Internet
|
|
22
|
+
Classifier: Topic :: System :: Monitoring
|
|
23
|
+
Classifier: Topic :: System :: Networking :: Monitoring
|
|
24
|
+
Classifier: Topic :: Utilities
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
Requires-Dist: blessed
|
|
27
|
+
Requires-Dist: bcc
|
|
28
|
+
Requires-Dist: pandas
|
|
29
|
+
|
|
30
|
+
# Bandwidth Ninja (bwninja)
|
|
31
|
+
|
|
32
|
+
A modern replacement for `iftop`
|
|
33
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
bwninja/__init__.py
|
|
4
|
+
bwninja/_version.py
|
|
5
|
+
bwninja/net.py
|
|
6
|
+
bwninja/tui.py
|
|
7
|
+
bwninja.egg-info/PKG-INFO
|
|
8
|
+
bwninja.egg-info/SOURCES.txt
|
|
9
|
+
bwninja.egg-info/dependency_links.txt
|
|
10
|
+
bwninja.egg-info/entry_points.txt
|
|
11
|
+
bwninja.egg-info/requires.txt
|
|
12
|
+
bwninja.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bwninja
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
|
|
2
|
+
[project]
|
|
3
|
+
name = "bwninja"
|
|
4
|
+
description="A modern replacement for iftop"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{name="Tom Flanagan", email="tom@zkpq.ca"}
|
|
8
|
+
]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 4 - Beta",
|
|
11
|
+
"Environment :: Console :: Curses",
|
|
12
|
+
"Environment :: Console",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Intended Audience :: End Users/Desktop",
|
|
15
|
+
"Intended Audience :: Information Technology",
|
|
16
|
+
"Intended Audience :: System Administrators",
|
|
17
|
+
"Intended Audience :: Telecommunications Industry",
|
|
18
|
+
"License :: Free For Educational Use",
|
|
19
|
+
"License :: Free For Home Use",
|
|
20
|
+
"License :: Free for non-commercial use",
|
|
21
|
+
"Operating System :: POSIX :: Linux",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python",
|
|
24
|
+
"Topic :: Internet",
|
|
25
|
+
"Topic :: System :: Monitoring",
|
|
26
|
+
"Topic :: System :: Networking :: Monitoring",
|
|
27
|
+
"Topic :: Utilities",
|
|
28
|
+
]
|
|
29
|
+
dynamic = ["version"]
|
|
30
|
+
|
|
31
|
+
dependencies = [
|
|
32
|
+
"blessed",
|
|
33
|
+
"bcc",
|
|
34
|
+
"pandas",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://bwninja.zkpq.ca"
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
bwninja = "bwninja.tui:main"
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["setuptools>=68"]
|
|
45
|
+
build-backend = "setuptools.build_meta"
|
|
46
|
+
|
|
47
|
+
[tool.setuptools]
|
|
48
|
+
packages = ["bwninja"]
|
|
49
|
+
|
|
50
|
+
[tool.setuptools.dynamic]
|
|
51
|
+
version = {attr = "bwninja._version.version"}
|
bwninja-1.0.0/setup.cfg
ADDED