Coverage for jutil/format.py: 78%

# Coverage for jutil/format.py : 78%

## 197 statements 157 run 40 missing 0 excluded 8 partial

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

1 import logging

2 import os

3 import re

4 import tempfile

5 from datetime import timedelta

6 from decimal import Decimal

7 import subprocess

8 from typing import List, Any, Optional, Union

9 from django.conf import settings

10 from django.utils.functional import lazy

11 import xml.dom.minidom # type: ignore

12

13

14 logger = logging.getLogger(__name__)

15

16

17 def 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(',', ' ')

28

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

33

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

39

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

45

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

51

52 # shorten last name to one letter

53 last_name = last_name[:1]

54

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

59

60

61 def 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()

86

87

88 def 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

111

112

113 def 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

136

137

138 def 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''

164

165

166 def 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.

174

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 = []

192

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)

199

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)

212

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)

230

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)

241

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))

247

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)

263

264

265 def 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:]

272

273

274 ucfirst_lazy = lazy(ucfirst, str)

275

276

277 def 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'))

284

285

286 def 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'))

293

294

295 def 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'))

302

303

304 def 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'))

311

312

313 def 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'))

320

321

322 def 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'))

329

330

331 def 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

347

348

349 def 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

362

363

364 def 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()

375

376

377 def 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