-
Notifications
You must be signed in to change notification settings - Fork 0
/
discus.py
executable file
·481 lines (402 loc) · 15.8 KB
/
discus.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
#!/usr/bin/python3
# Discus is a program that reports hard drive space usage.
# Copyright 2000 Stormy Henderson ([email protected]).
# Copyright 2019-2021 Nicolas Carrier ([email protected]).
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307
# USA.
import subprocess
import os
import sys
import re
import copy
import shutil
from collections import namedtuple
import argparse
from argparse import RawTextHelpFormatter
# These colors should work on VT100-type displays and can be overridden by the
# user.
black = "\033[30m"
red = "\033[31m"
green = "\033[32m"
yellow = "\033[33m"
blue = "\033[34m"
magenta = "\033[35m"
cyan = "\033[36m"
white = "\033[37m"
on_black = "\033[40m"
on_red = "\033[41m"
on_green = "\033[42m"
on_yellow = "\033[43m"
on_blue = "\033[44m"
on_magenta = "\033[45m"
on_cyan = "\033[46m"
on_white = "\033[47m"
normal = "\033[0m"
bold = "\033[1m"
underline = "\033[4m"
blink = "\033[5m"
reverse = "\033[7m"
dim = "\033[8m"
opts = {
"placing": True,
"reserved": True,
# Labels to display next to numbers.
"akabytes": ["KB", "MB", "GB", "TB", "PB", "EB"],
"color": 1,
# Power of 1024, smallest value is 0, for KB, used only when smart
# formatting is disabled, overridden by -t, -g, -m and -k options
"divisor": 1,
"graph": 1,
"graph_char": "*",
"graph_fill": "-",
# Example mtab entry that uses a shell command (always use a ! as
# first character) rather than a file:
# !/bin/mount |awk '{print $1, $3}'"
"mtab": "/etc/mtab",
# Number of decimal places to display, same as -p
"places": 1,
# Filesystems to ignore.
"skip_list": ["/dev/pts", "/proc", "/dev", "/proc/usb", "/sys"],
# Use smart formatting of numbers.
"smart": 1,
"color_header": blue,
"color_normal": normal,
"color_safe": normal, # 0%- 70% full
"color_warning": bold + yellow, # 70%- 85% full
"color_danger": bold + red # 85%-100% full
}
HOMEPAGE = "https://github.com/ncarrier/discus"
VERSION = "0.5.0"
MINIMUM_WIDTH = 68
# values taken from sysexit.h
EX_OK = 0
EX_USAGE = 64
EX_CONFIG = 78
class StatsFactory:
"""Factory class to get statistics about a mount point."""
Stats = namedtuple('Stats', ['total', 'free', 'used'])
def __init__(self, reserved):
"""Constructor, initialize private fields."""
self.__reserved = reserved
def getStats(self, mount):
"""Gather statistics about specified filesystem."""
try:
stats = os.statvfs(mount)
except PermissionError:
return StatsFactory.Stats(total="-", free="-", used="-")
total = stats.f_blocks * stats.f_frsize
# if we have to take care of reserved space for root, then use
# available blocks (but keep counting free space with all free blocks)
if self.__reserved:
free = stats.f_bavail * stats.f_frsize
else:
free = stats.f_bfree * stats.f_frsize
used = total - stats.f_bfree * stats.f_frsize
return StatsFactory.Stats(total=total, free=free, used=used)
class SizeFormatter:
"""
Class responsible of formatting sizes, smartly or not.
if opts["smart"] is false, divisor will be used to divide the size to the
corresponding unit, that is 0 -> KB, 1 -> MB... Supposing that the size is
fed in kilo bytes.
"""
DEFAULT_AKABYTES = ["KB", "MB", "GB", "TB", "PB", "EB"]
# helper class for manipulating options
Options = namedtuple("Options", ["smart", "placing", "akabytes", "places",
"divisor"])
Options.__new__.__defaults__ = (True, True, DEFAULT_AKABYTES, 1, 1)
def __init__(self, smart, placing, akabytes, places, divisor):
"""Constructor, initialize private fields."""
self.__smart = smart
self.__placing = placing
self.__akabytes = akabytes.copy()
self.__places = places
self.__divisor = divisor
# Is smart display enabled?
self.__formatter = (self.__smart_format if self.__smart
else self.__manual_format)
def format(self, size):
"""
Format the size for human use.
size: size in kilobytes.
"""
size, divisor, places = self.__formatter(size)
if size == 0:
places = 0
unit = self.__akabytes[divisor]
# And now actually format the result.
result = f"{size:0.{places}f} {unit}"
return result
def __smart_format(self, size):
"""
Use smart formatting, which increases the divisor until size is 3 or
less digits in size.
"""
# Keep reducing digits until there are 3 or less.
count = 0
while size > (0.9999999999 * pow(10, 3)):
# But don't let it get too small, either.
if (size / 1024.0) < 0.05:
break
size = size / 1024.0
count = count + 1
# Display a proportionate number of decimal places to the number of
# main digits.
if not self.__placing:
if count < 2:
fudge = count
else:
fudge = 2
else:
# User specified how many decimal places were wanted.
fudge = self.__places
return size, count, fudge
def __manual_format(self, size):
"""
We're not using smart display, so figure things up on the specified
KB/MB/GB/TB basis.
"""
divisor = self.__divisor
size = size / pow(1024.0, divisor)
return size, divisor, self.__places
Mount = namedtuple('Mount', ['mount', 'device'])
class DiskData:
"""
Class representing a disk's data, formatted for output, in string form.
"""
Base = namedtuple('BaseDiskData',
['percent', 'total', 'used', 'free', 'mount', 'device'])
@staticmethod
def get(stats, percent, mount, size_formatter):
"""Factory method returning a BaseDiskData instance."""
sf = size_formatter
if not isinstance(percent, str):
percent = f"{percent:.1f}%"
total = sf.format(stats.total / 1024)
used = sf.format(stats.used / 1024)
free = sf.format(stats.free / 1024)
else:
total = stats.total
used = stats.used
free = stats.free
return DiskData.Base(percent,
total,
used,
free,
mount.mount,
mount.device)
class Disk:
"""Contains everything needed to represent a disk textually."""
def __init__(self, mount, stats_factory, size_formatter):
"""
Collect the stats when the object is created, and store them for later,
when a report is requested.
"""
stats = stats_factory.getStats(mount.mount)
if isinstance(stats.free, str):
self.__percent = "-"
else:
self.__percent = self.__percentage(stats.free, stats.total)
self.__data = DiskData.get(stats, self.__percent, mount,
size_formatter)
def report(self):
"""Generate a report, and return it as text."""
d = self.__data
return [d.mount if opts["graph"] else d.device, d.total, d.used,
d.free, d.percent,
self.__percent if opts["graph"] else d.mount]
@staticmethod
def graph(percent, width):
"""Format a percentage as a bar graph."""
# How many stars to place?
# -4 accounts for the [] and the starting space
width = width - 4
if isinstance(percent, str):
bar_width = 0
else:
bar_width = int(round(percent * width / 100))
# Now generate the string, using the characters in the config file.
result = color("safe")
graph_char = opts["graph_char"]
graph_fill = opts["graph_fill"]
for counter in range(0, bar_width):
if counter >= 0.7 * width and counter < 0.85 * width:
result = result + color("warning")
elif counter >= 0.85 * width:
result = result + color("danger")
result = result + graph_char
result = result + (width - bar_width) * graph_fill
return " [" + color("safe") + result + color("normal") + "]"
@staticmethod
def __percentage(free, total):
"""Compute the percentage of space used."""
if total == 0:
return 0.0
return 100 * (1.0 - free / total)
@staticmethod
def trim(text, width):
"""Don't let long names mess up the display: shorten them."""
where = len(text)
where = where - width
if where > 0:
text = "+" + text[where:]
return text
def parse_options(args=sys.argv[1:]):
""""""
parser = argparse.ArgumentParser(description=f"Discus version {VERSION}, "
"to display disk usage.",
formatter_class=RawTextHelpFormatter)
parser.add_argument("-d", "--device", action="store_true",
default=False, help="show device instead of graph")
parser.add_argument("-c", "--color", action="store_false", default=True,
help="disable color")
parser.add_argument("-g", "--gigabytes", action="store_const", const=2,
dest="divisor", help="display sizes in gigabytes")
parser.add_argument("-k", "--kilobytes", action="store_const", const=0,
dest="divisor", help="display sizes in kilobytes")
parser.add_argument("-m", "--megabytes", action="store_const", const=1,
dest="divisor", help="display sizes in megabytes")
parser.add_argument("-p", "--places", type=int, choices=range(0, 10),
help="number of digits to right of decimal place")
parser.add_argument("-r", "--reserved", action="store_true",
default=False, help="count reserved space as used")
parser.add_argument("-s", "--smart", action="store_false", default=True,
help="do not use smart formatting")
parser.add_argument("-t", "--terabytes", action="store_const", const=3,
dest="divisor", help="display sizes in terabytes")
parser.add_argument("-v", "--version", action="version",
version=(f"Discus version {VERSION} by Nicolas "
"Carrier ([email protected])\n"
f"Home page: {HOMEPAGE}"))
return parser.parse_args(args)
def interpret_options(o):
opts["smart"] = 1 if o.smart else 0
if o.divisor is not None:
if o.places is None:
opts["places"] = o.divisor
opts["divisor"] = o.divisor
opts["smart"] = 0
if o.places is not None:
opts["placing"] = True
opts["places"] = o.places
opts["graph"] = 0 if o.device else 1
opts["color"] = 1 if o.color else 0
opts["reserved"] = 1 if o.reserved else 0
def read_mounts(mtab, skip_list):
"""Read the mounts file."""
mounts = []
# If the first letter of the mtab file begins with a !, it is a
# shell command to be executed, and not a file to be read. Idea
# provided by John Soward.
if mtab[0] == "!":
mtab = subprocess.getoutput(mtab[1:])
mtab = str.split(mtab, "\n")
else:
fp = open(mtab)
mtab = fp.readlines()
fp.close()
# Extract the mounted filesystems from the read file.
for entry in mtab:
entry = str.split(entry)
device = entry[0]
mount = entry[1]
# Sandro Tosi - to fix Debian bug 291276, convert escaped octal values
# from /etc/mtab (or /proc/mounts) to real characters
for octc in re.findall(r'\\(\d{3})', mount):
mount = mount.replace(r'\%s' % octc, chr(int(octc, 8)))
# Skip entries we aren't interested in.
if mount in skip_list:
continue
mounts.append(Mount(mount, device))
return mounts
def color(code):
"""Function that return color codes if color is enabled."""
if opts["color"]:
return opts["color_" + code]
return ""
def get_header(graph):
"""Generate a list of headers."""
# Has the user requested to see device names instead of a graph?
if graph:
return ["Mount", "Total", "Used", "Avail", "Prcnt", " Graph"]
else:
return ["Device", "Total", "Used", "Avail", "Prcnt", " Mount"]
def format_fields(f, w):
"""
Format a list of fields into one string, given a list of corresponding
widths.
"""
a = ["", ">", ">", ">", ">", ""]
return " ".join([f"{f[i]:{a[i]}{w[i]}}" for i in range(0, len(w))])
def get_layout(headers, reports):
graph_column_width = 14
widths = [11, 11, 12, 12, 8, graph_column_width]
data = [copy.deepcopy(headers)] + copy.deepcopy(reports)
size = shutil.get_terminal_size((MINIMUM_WIDTH, 20))
# limit the width to a minimum and account to the inter-column gap
columns = max(MINIMUM_WIDTH, size.columns - len(widths))
for datum in data:
for i, v in enumerate(datum[:-1]):
if len(v) > widths[i]:
widths[i] = len(v)
widths[-1] = columns - sum(widths[:-1]) - 10
if widths[-1] < graph_column_width:
widths[-1] = graph_column_width
widths[0] = columns - sum(widths[1:])
return widths
def main():
"""Define main program."""
options = parse_options()
interpret_options(options)
mounts = read_mounts(opts["mtab"], opts["skip_list"])
headers = get_header(opts["graph"])
stats_factory = StatsFactory(opts["reserved"])
size_formatter = SizeFormatter(opts["smart"], opts["placing"],
opts["akabytes"], opts["places"],
opts["divisor"])
# Create a disk object for each mount, store its report.
reports = [Disk(m, stats_factory, size_formatter).report() for m in mounts]
widths = get_layout(headers, reports)
print(color("header") + format_fields(headers, widths))
for report in reports:
if opts["graph"]:
r = report[:-1] + [Disk.graph(report[-1], widths[-1])]
else:
r = report[:-1] + [" " + Disk.trim(report[-1], widths[-1] - 2)]
# trim mount field if it exceeds its alloted width
if len(r[0]) >= widths[0]:
r[0] = Disk.trim(r[0], widths[0] - 1)
print(color("normal") + format_fields(r, widths) + color("clear"))
if __name__ == "__main__":
# Before starting, we try to load the configuration files which may
# override global objects' values.
# First the global /etc file, then the user's file, if it exists.
try:
exec(compile(open("/etc/discusrc", "rb").read(), "/etc/discusrc",
'exec'))
except IOError:
pass
try:
exec(compile(open(os.environ['HOME'] + "/.discusrc", "rb").read(),
os.environ['HOME'] + "/.discusrc", 'exec'))
except IOError:
pass
# Add internal color setting.
opts["color_clear"] = normal
if "stat_prog" in opts:
print("support for stat_prog option has been removed in 0.3.0",
file=sys.stderr)
main()