Coverage for jutil/format.py : 78%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1import logging
2import os
3import re
4import tempfile
5from datetime import timedelta
6from decimal import Decimal
7import subprocess
8from typing import List, Any, Optional, Union
9from django.conf import settings
10from django.utils.functional import lazy
11import xml.dom.minidom # type: ignore
14logger = logging.getLogger(__name__)
17def format_full_name(first_name: str, last_name: str, max_length: int = 20) -> str:
18 """
19 Limits name length to specified length. Tries to keep name as human-readable an natural as possible.
20 :param first_name: First name
21 :param last_name: Last name
22 :param max_length: Maximum length
23 :return: Full name of shortened version depending on length
24 """
25 # dont allow commas in limited names
26 first_name = first_name.replace(',', ' ')
27 last_name = last_name.replace(',', ' ')
29 # accept short full names as is
30 original_full_name = first_name + ' ' + last_name
31 if len(original_full_name) <= max_length:
32 return original_full_name
34 # drop middle names
35 first_name = first_name.split(' ')[0]
36 full_name = first_name + ' ' + last_name
37 if len(full_name) <= max_length:
38 return full_name
40 # drop latter parts of combined first names
41 first_name = re.split(r'[\s\-]', first_name)[0]
42 full_name = first_name + ' ' + last_name
43 if len(full_name) <= max_length:
44 return full_name
46 # drop latter parts of multi part last names
47 last_name = re.split(r'[\s\-]', last_name)[0]
48 full_name = first_name + ' ' + last_name
49 if len(full_name) <= max_length:
50 return full_name
52 # shorten last name to one letter
53 last_name = last_name[:1]
55 full_name = first_name + ' ' + last_name
56 if len(full_name) > max_length:
57 raise Exception('Failed to shorten name {}'.format(original_full_name))
58 return full_name
61def format_timedelta(dt: timedelta, days_label: str = 'd', hours_label: str = 'h',
62 minutes_label: str = 'min', seconds_label: str = 's') -> str:
63 """
64 Formats timedelta to readable format, e.g. 1h30min15s.
65 :param dt: timedelta
66 :param days_label: Label for days. Leave empty '' if value should be skipped / ignored.
67 :param hours_label: Label for hours. Leave empty '' if value should be skipped / ignored.
68 :param minutes_label: Label for minutes. Leave empty '' if value should be skipped / ignored.
69 :param seconds_label: Label for seconds. Leave empty '' if value should be skipped / ignored.
70 :return: str
71 """
72 parts = (
73 (86400, days_label),
74 (3600, hours_label),
75 (60, minutes_label),
76 (1, seconds_label),
77 )
78 out = ""
79 seconds = int(dt.total_seconds())
80 for n_secs, label in parts:
81 n, remainder = divmod(seconds, n_secs)
82 if n > 0 and label:
83 out += str(n) + label
84 seconds = remainder
85 return out.strip()
88def format_xml(content: str, encoding: str = 'UTF-8', exceptions: bool = False) -> str:
89 """
90 Formats XML document as human-readable plain text.
91 If settings.XMLLINT_PATH is defined xmllint is used for formatting (higher quality). Otherwise minidom toprettyxml is used.
92 :param content: XML data as str
93 :param encoding: XML file encoding
94 :param exceptions: Raise exceptions on error
95 :return: str (Formatted XML str)
96 """
97 assert isinstance(content, str)
98 try:
99 if hasattr(settings, 'XMLLINT_PATH') and settings.XMLLINT_PATH: 99 ↛ 105line 99 didn't jump to line 105, because the condition on line 99 was never false
100 with tempfile.NamedTemporaryFile() as fp:
101 fp.write(content.encode(encoding=encoding))
102 fp.flush()
103 out = subprocess.check_output([settings.XMLLINT_PATH, '--format', fp.name])
104 return out.decode(encoding=encoding)
105 return xml.dom.minidom.parseString(content).toprettyxml()
106 except Exception as e:
107 logger.error('format_xml failed: %s', e)
108 if exceptions:
109 raise
110 return content
113def format_xml_bytes(content: bytes, encoding: str = 'UTF-8', exceptions: bool = False) -> bytes:
114 """
115 Formats XML document as human-readable plain text and returns result in bytes.
116 If settings.XMLLINT_PATH is defined xmllint is used for formatting (higher quality). Otherwise minidom toprettyxml is used.
117 :param content: XML data as bytes
118 :param encoding: XML file encoding
119 :param exceptions: Raise exceptions on error
120 :return: bytes (Formatted XML as bytes)
121 """
122 assert isinstance(content, bytes)
123 try:
124 if hasattr(settings, 'XMLLINT_PATH') and settings.XMLLINT_PATH: 124 ↛ 130line 124 didn't jump to line 130, because the condition on line 124 was never false
125 with tempfile.NamedTemporaryFile() as fp:
126 fp.write(content)
127 fp.flush()
128 out = subprocess.check_output([settings.XMLLINT_PATH, '--format', fp.name])
129 return out
130 return xml.dom.minidom.parseString(content.decode(encoding=encoding)).toprettyxml(encoding=encoding)
131 except Exception as e:
132 logger.error('format_xml_bytes failed: %s', e)
133 if exceptions:
134 raise
135 return content
138def format_xml_file(full_path: str, encoding: str = 'UTF-8', exceptions: bool = False) -> bytes:
139 """
140 Formats XML file as human-readable plain text and returns result in bytes.
141 Tries to format XML file first, if formatting fails the file content is returned as is.
142 If the file does not exist empty bytes is returned.
143 If settings.XMLLINT_PATH is defined xmllint is used for formatting (higher quality). Otherwise minidom toprettyxml is used.
144 :param full_path: Full path to XML file
145 :param encoding: XML file encoding
146 :param exceptions: Raise exceptions on error
147 :return: bytes
148 """
149 try:
150 if hasattr(settings, 'XMLLINT_PATH') and settings.XMLLINT_PATH:
151 return subprocess.check_output([settings.XMLLINT_PATH, '--format', full_path])
152 with open(full_path, 'rb') as fp:
153 return xml.dom.minidom.parse(fp).toprettyxml(encoding=encoding)
154 except Exception as e:
155 logger.error('format_xml_file failed (1): %s', e)
156 if exceptions:
157 raise
158 try:
159 with open(full_path, 'rb') as fp:
160 return fp.read()
161 except Exception as e:
162 logger.error('format_xml_file failed (2): %s', e)
163 return b''
166def format_table(rows: List[List[Any]], max_col: Optional[int] = 10, max_line: Optional[int] = 200, # noqa
167 col_sep: str = '|', row_sep: str = '-', row_begin: str = '|', row_end: str = '|',
168 has_label_row: bool = False,
169 left_align: Optional[List[int]] = None, center_align: Optional[List[int]] = None) -> str:
170 """
171 Formats "ASCII-table" rows by padding column widths to longest column value, optionally limiting column widths.
172 Optionally separates colums with ' | ' character and header row with '-' characters.
173 Supports left, right and center alignment. Useful for console apps / debugging.
175 :param rows: List[str]
176 :param max_col: Max column value width. Pass None for unlimited length.
177 :param max_line: Maximum single line length. Exceeding columns truncated. Pass None for unlimited length.
178 :param col_sep: Column separator string.
179 :param row_sep: Row separator character used before first row, end, after first row (if has_label_row).
180 :param row_begin: Row begin string, inserted before each row.
181 :param row_end: Row end string, appended after each row.
182 :param has_label_row: Set to True if table starts with column label row.
183 :param left_align: Indexes of left-aligned columns. By default all are right aligned.
184 :param center_align: Indexes of center-aligned columns. By default all are right aligned.
185 :return: str
186 """
187 assert max_col is None or max_col > 2
188 if left_align is None:
189 left_align = []
190 if center_align is None:
191 center_align = []
193 ncols = 0
194 col_lens: List[int] = []
195 for row in rows:
196 ncols = max(ncols, len(row))
197 while len(col_lens) < ncols:
198 col_lens.append(0)
200 lines = []
201 for row in rows:
202 line = []
203 for ix, v in enumerate(row):
204 v = str(v)
205 if max_col is not None and len(v) > max_col:
206 v = v[:max_col-2] + '..'
207 line.append(v)
208 col_lens[ix] = max(col_lens[ix], len(v))
209 while len(line) < ncols: 209 ↛ 210line 209 didn't jump to line 210, because the condition on line 209 was never true
210 line.append('')
211 lines.append(line)
213 lines2 = []
214 for line in lines:
215 line2 = []
216 for ix, v in enumerate(line):
217 col_len = col_lens[ix]
218 if len(v) < col_len:
219 if ix in left_align:
220 v = v + ' ' * (col_len - len(v))
221 elif ix in center_align:
222 pad = col_len - len(v)
223 lpad = int(pad/2)
224 rpad = pad - lpad
225 v = ' ' * lpad + v + ' '*rpad
226 else:
227 v = ' '*(col_len-len(v)) + v
228 line2.append(v)
229 lines2.append(line2)
231 max_line_len = 0
232 col_sep_len = len(col_sep)
233 ncols0 = ncols
234 for line in lines2:
235 if max_line is not None: 235 ↛ 234line 235 didn't jump to line 234, because the condition on line 235 was never false
236 line_len = len(row_begin) + sum(len(v)+col_sep_len for v in line[:ncols]) - col_sep_len + len(row_end)
237 while line_len > max_line:
238 ncols -= 1
239 line_len = len(row_begin) + sum(len(v)+col_sep_len for v in line[:ncols]) - col_sep_len + len(row_end)
240 max_line_len = max(max_line_len, line_len)
242 line_term = ''
243 row_sep_term = ''
244 if ncols0 > ncols:
245 line_term = '..'
246 row_sep_term = row_sep * int(2 / len(row_sep))
248 lines3 = []
249 if row_sep: 249 ↛ 251line 249 didn't jump to line 251, because the condition on line 249 was never false
250 lines3.append(row_sep * max_line_len + row_sep_term)
251 for line_ix, line in enumerate(lines2):
252 while len(line) > ncols:
253 line.pop()
254 line_out = col_sep.join(line)
255 lines3.append(row_begin + line_out + row_end + line_term)
256 if line_ix == 0 and row_sep and has_label_row:
257 lines3.append(row_sep * max_line_len + row_sep_term)
258 if line_ix >= ncols: 258 ↛ 259line 258 didn't jump to line 259, because the condition on line 258 was never true
259 break
260 if row_sep: 260 ↛ 262line 260 didn't jump to line 262, because the condition on line 260 was never false
261 lines3.append(row_sep * max_line_len + row_sep_term)
262 return '\n'.join(lines3)
265def ucfirst(v: str) -> str:
266 """
267 Converts first character of the string to uppercase.
268 :param v: str
269 :return: str
270 """
271 return v[0:1].upper() + v[1:]
274ucfirst_lazy = lazy(ucfirst, str)
277def dec1(a: Union[float, int, Decimal, str]) -> Decimal:
278 """
279 Converts number to Decimal with 1 decimal digits.
280 :param a: Number
281 :return: Decimal with 1 decimal digits
282 """
283 return Decimal(a).quantize(Decimal('1.0'))
286def dec2(a: Union[float, int, Decimal, str]) -> Decimal:
287 """
288 Converts number to Decimal with 2 decimal digits.
289 :param a: Number
290 :return: Decimal with 2 decimal digits
291 """
292 return Decimal(a).quantize(Decimal('1.00'))
295def dec3(a: Union[float, int, Decimal, str]) -> Decimal:
296 """
297 Converts number to Decimal with 3 decimal digits.
298 :param a: Number
299 :return: Decimal with 3 decimal digits
300 """
301 return Decimal(a).quantize(Decimal('1.000'))
304def dec4(a: Union[float, int, Decimal, str]) -> Decimal:
305 """
306 Converts number to Decimal with 4 decimal digits.
307 :param a: Number
308 :return: Decimal with 4 decimal digits
309 """
310 return Decimal(a).quantize(Decimal('1.0000'))
313def dec5(a: Union[float, int, Decimal, str]) -> Decimal:
314 """
315 Converts number to Decimal with 5 decimal digits.
316 :param a: Number
317 :return: Decimal with 4 decimal digits
318 """
319 return Decimal(a).quantize(Decimal('1.00000'))
322def dec6(a: Union[float, int, Decimal, str]) -> Decimal:
323 """
324 Converts number to Decimal with 6 decimal digits.
325 :param a: Number
326 :return: Decimal with 4 decimal digits
327 """
328 return Decimal(a).quantize(Decimal('1.000000'))
331def strip_media_root(file_path: str) -> str:
332 """
333 If file path starts with (settings) MEDIA_ROOT,
334 the MEDIA_ROOT part gets stripped and only relative path is returned.
335 Otherwise file path is returned as is. This enabled stored file names in more
336 portable format for different environment / storage.
337 If MEDIA_ROOT is missing or empty, the filename is returned as is.
338 Reverse operation of this is get_media_full_path().
339 :param file_path: str
340 :return: str
341 """
342 if hasattr(settings, 'MEDIA_ROOT') and settings.MEDIA_ROOT and file_path.startswith(settings.MEDIA_ROOT):
343 file_path = file_path[len(settings.MEDIA_ROOT):]
344 if file_path.startswith('/'): 344 ↛ 346line 344 didn't jump to line 346, because the condition on line 344 was never false
345 return file_path[1:]
346 return file_path
349def get_media_full_path(file_path: str) -> str:
350 """
351 If file path is absolute, the path is returned as is.
352 Otherwise it is returned relative to (settings) MEDIA_ROOT.
353 This enabled stored file names in more portable format for different environment / storage.
354 If MEDIA_ROOT is missing or empty, the filename is returned as is.
355 Reverse operation of this is strip_media_root().
356 :param file_path: str
357 :return: str
358 """
359 if file_path and not os.path.isabs(file_path):
360 return os.path.join(settings.MEDIA_ROOT, file_path)
361 return file_path
364def camel_case_to_underscore(s: str) -> str:
365 """
366 Converts camelCaseWord to camel_case_word.
367 :param s: str
368 :return: str
369 """
370 if s:
371 s = re.sub(r"([A-Z]+)([A-Z][a-z])", r'\1_\2', s)
372 s = re.sub(r"([a-z\d])([A-Z])", r'\1_\2', s)
373 s = s.replace("-", "_")
374 return s.lower()
377def underscore_to_camel_case(s: str) -> str:
378 """
379 Converts under_score_word to underScoreWord.
380 :param s: str
381 :return: str
382 """
383 if s:
384 p = s.split('_')
385 s = p[0] + ''.join([ucfirst(w) for w in p[1:]])
386 return s