Increase version to 1.7.4
[pyi2ncommon] / src / text_helpers.py
... / ...
CommitLineData
1# The software in this package is distributed under the GNU General
2# Public License version 2 (with a special exception described below).
3#
4# A copy of GNU General Public License (GPL) is included in this distribution,
5# in the file COPYING.GPL.
6#
7# As a special exception, if other files instantiate templates or use macros
8# or inline functions from this file, or you compile this file and link it
9# with other works to produce a work based on this file, this file
10# does not by itself cause the resulting work to be covered
11# by the GNU General Public License.
12#
13# However the source code for this file must still be made available
14# in accordance with section (3) of the GNU General Public License.
15#
16# This exception does not invalidate any other reasons why a work based
17# on this file might be covered by the GNU General Public License.
18#
19# Copyright (c) 2016-2018 Intra2net AG <info@intra2net.com>
20
21"""
22Functions for improving textual output.
23
24Copyright: 2015 Intra2net AG
25
26This module has two parts. Part 1 includes:
27 - head_and_tail: shows the first few and last few elements of an iterable that
28 could potentially be pretty long
29 - size_str: textual representation of data size
30
31Part2 contains functions for coloring text, a poor-man's version of other
32modules like :py:mod:`colorclass` (which is now also available on Intra2net
33systems)
34
35Functions might cause trouble when combined, e.g.::
36
37 bold('this is bold and ' + red('red') + ' and ' + green('green'))
38
39will show the text "and green" not in bold. May have to try using specific
40end-of-color or end-of-style escape sequences instead of 0 (reset-everything).
41
42
43.. seealso:: http://stackoverflow.com/questions/287871/print-in-terminal-with-colors-using-python
44.. seealso:: https://en.wikipedia.org/wiki/ANSI_escape_code
45.. seealso:: :py:mod:`textwrap`
46"""
47
48from builtins import print as _builtin_print
49
50from functools import partial
51from itertools import islice
52import re
53
54from .type_helpers import isstr
55from sys import stdout
56
57
58def head_and_tail(iterable, n_head=20, n_tail=20, n_elems=None,
59 skip_elem="...(skipping {n_skipped} elements)..."):
60 """
61 Convenient way to shorten a possibly very long iterable before printing.
62
63 Will not modify short iterables, but for longer lists/tuples/... will only
64 yield first few, then a message how many were skipped and the last few
65
66 The iterable does not even have to have a `len(..)` method if argument
67 `n_elems` is given. Only needs a `next(..)` method. However, for very long
68 iterables this will be faster if random element access is provided via []
69
70 :param iterable: an iterable
71 :type iterable: anything that can be iterated over
72 :param int n_head: number of starting elements to yield (optional)
73 :param int n_tail: number of ending elements to yield (optional)
74 :param int n_elems: number of elements in iterable; give this to avoid a
75 call to `len(iterable)` (optional)
76 :param skip_elem: element to replace bulge of skipped elements; yielded
77 once at most; None to not yield a skip replacement; if str
78 then it will be formatted; optional, defaults to string
79 with number of skipped elems
80 :type skip_elem: anything you like
81 :yields: `n_head+n_tail` elements from iterable plus the `skip_elem` (or
82 less if iterable is shorter than this).
83
84 .. seealso:: :py:func:`slice`, :py:func:`itertools.islice`, :py:func:`textwrap.shorten`
85
86 """
87 if n_elems is None:
88 n_elems = len(iterable)
89
90 # yield first n_head elems
91 index = 0
92 for elem in iterable:
93 index += 1
94 if index > n_head:
95 break
96 yield elem
97
98 # yield skip element
99 if n_elems > n_head + n_tail:
100 if skip_elem is not None:
101 if isstr(skip_elem):
102 yield skip_elem.format(n_skipped=n_elems-n_head-n_tail)
103 else:
104 yield skip_elem
105 elif n_elems <= n_head:
106 return
107
108 # yield end
109 try:
110 # try to access end directly
111 for elem in iterable[n_elems-n_tail:]:
112 yield elem
113 except TypeError:
114 # if this did not work, then need to iterate through whole thing
115 # do this as in itertool recipe for consume():
116 n_skip = n_elems - n_head - n_tail - 1
117 next(islice(iterable, n_skip, n_skip), None)
118 for elem in iterable:
119 yield elem
120
121
122def size_str(byte_number, is_diff=False):
123 """
124 Create a human-readable text representation of a file size.
125
126 Rounds and shortens size to something easily human-readable like '1.5 GB'.
127
128 :param float byte_number: Number of bytes to express as text
129 :param bool is_diff: Set to True to include a '+' or '-' in output;
130 default: False
131 :returns: textual representation of data
132 :rtype: str
133 """
134 # constants
135 units = '', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'
136 factor = 1024
137 thresh_add_comma = 10. # below this, return 1.2G, above this return 12G
138
139 # prepare
140 if byte_number < 0:
141 sign_str = '-'
142 elif is_diff:
143 sign_str = '+'
144 else:
145 sign_str = ''
146 curr_num = abs(float(byte_number))
147
148 # loop
149 for unit in units:
150 if curr_num > factor:
151 curr_num /= factor
152 continue
153 elif curr_num < thresh_add_comma and unit != 'B': # e.g. 1.2G
154 return '{2}{0:.1f} {1}B'.format(curr_num, unit, sign_str)
155 else: # e.g. 12G or 1B
156 return '{2}{0:d} {1}B'.format(int(round(curr_num)), unit, sign_str)
157
158 # have an impossible amount of data. (>1024**4 GB)
159 # undo last "/factor" and show thousand-separator
160 return f'{sign_str}{int(round(curr_num*factor)):,d} {units[-1]}B'
161
162
163###############################################################################
164# TEXT FORMATTING/COLORING
165###############################################################################
166
167COLOR_BLACK = 'black'
168COLOR_RED = 'red'
169COLOR_GREEN = 'green'
170COLOR_YELLOW = 'yellow'
171COLOR_BLUE = 'blue'
172COLOR_MAGENTA = 'magenta'
173COLOR_CYAN = 'cyan'
174COLOR_WHITE = 'white'
175
176STYLE_NORMAL = 0
177STYLE_BOLD = 1
178STYLE_UNDERLINE = 4
179STYLE_BLINK = 5
180STYLE_REVERSE = 7
181
182
183_COLOR_TO_CODE = dict(zip([COLOR_BLACK, COLOR_RED, COLOR_GREEN, COLOR_YELLOW,
184 COLOR_BLUE, COLOR_MAGENTA, COLOR_CYAN, COLOR_WHITE],
185 range(8)))
186
187_ANSI_ESCAPE_SURROUND = '\x1b[{}m{}\x1b[0m'
188
189# only color output if we are writing output to a terminal (not a file or so)
190try:
191 _STDOUT_IS_TTY = stdout.isatty()
192except Exception:
193 # stdout might be some wrapper around the real thing to capture output
194 _STDOUT_IS_TTY = False
195
196
197def strip_color(text):
198 """return same text but without any ansi color sequences"""
199 return re.sub(r"\x1b\[[\d;]*[mD]", "", text, count=0, flags=re.IGNORECASE)
200
201def colored(text, foreground=None, background=None, style=None):
202 """ return text with given foreground/background ANSI color escape sequence
203
204 :param str text: text to color
205 :param str style: one of the style constants above
206 :param str foreground: one of the color constants to use for text color
207 or None to leave as-is
208 :param str background: one of the color constants to use for background
209 or None to leave as-is
210 :param style: single STYLE constant or iterable of those
211 or None to leave as-is
212 :returns: text surrounded in ansi escape sequences
213 """
214
215 if foreground is None and background is None and style is None:
216 return text
217
218 prefixes = []
219 if foreground:
220 prefixes.append(str(30 + _COLOR_TO_CODE[foreground]))
221 if background:
222 prefixes.append(str(40 + _COLOR_TO_CODE[background]))
223 if style is None:
224 pass
225 elif isinstance(style, int):
226 prefixes.append(str(style))
227 else: # assume iterable of ints
228 prefixes.extend(str(style_item) for style_item in style)
229
230 return _ANSI_ESCAPE_SURROUND.format(';'.join(prefixes), text)
231
232
233def print(text, *args, **kwargs): # pylint: disable=redefined-builtin
234 """ like the builtin print function but also accepts color args
235
236 If any arg of :py:func:`colored` is given in `kwargs`, will run text with
237 color-args through that function. Runs built-in :py:func:`print`
238 function with result and other args.
239
240 ...todo:: color all args, not just first
241 """
242 foreground = None
243 background = None
244 style = None
245
246 # remove color info from kwargs
247 try:
248 foreground = kwargs['foreground']
249 del kwargs['foreground']
250 except KeyError:
251 pass
252
253 try:
254 background = kwargs['background']
255 del kwargs['background']
256 except KeyError:
257 pass
258
259 try:
260 style = kwargs['style']
261 del kwargs['style']
262 except KeyError:
263 pass
264
265 if _STDOUT_IS_TTY:
266 text_c = colored(text, foreground, background, style)
267 else:
268 text_c = text
269
270 _builtin_print(text_c, *args, **kwargs)
271
272
273black = partial(print, foreground=COLOR_BLACK)
274red = partial(print, foreground=COLOR_RED)
275green = partial(print, foreground=COLOR_GREEN)
276yellow = partial(print, foreground=COLOR_YELLOW)
277blue = partial(print, foreground=COLOR_BLUE)
278magenta = partial(print, foreground=COLOR_MAGENTA)
279cyan = partial(print, foreground=COLOR_CYAN)
280white = partial(print, foreground=COLOR_WHITE)
281
282normal = partial(print, style=STYLE_NORMAL)
283bold = partial(print, style=STYLE_BOLD)
284underline = partial(print, style=STYLE_UNDERLINE)
285blink = partial(print, style=STYLE_BLINK)
286reverse = partial(print, style=STYLE_REVERSE)