1 """ 2 Utilities for working with strings and text. 3 4 Inheritance diagram: 5 6 .. inheritance-diagram:: IPython.utils.text 7 :parts: 3 8 """ 9 10 import os 11 import re 12 import string 13 import sys 14 import textwrap 15 import warnings 16 from string import Formatter 17 from pathlib import Path 18 19 from typing import ( 20 List, 21 Dict, 22 Tuple, 23 Optional, 24 cast, 25 Sequence, 26 Mapping, 27 Any, 28 Union, 29 Callable, 30 Iterator, 31 TypeVar, 32 ) 33 34 if sys.version_info < (3, 12): 35 from typing_extensions import Self 36 else: 37 from typing import Self 38 39 40 class LSString(str): 41 """String derivative with a special access attributes. 42 43 These are normal strings, but with the special attributes: 44 45 .l (or .list) : value as list (split on newlines). 46 .n (or .nlstr): original value (the string itself). 47 .s (or .spstr): value as whitespace-separated string. 48 .p (or .paths): list of path objects (requires path.py package) 49 50 Any values which require transformations are computed only once and 51 cached. 52 53 Such strings are very useful to efficiently interact with the shell, which 54 typically only understands whitespace-separated options for commands.""" 55 56 __list: List[str] 57 __spstr: str 58 __paths: List[Path] 59 60 def get_list(self) -> List[str]: 61 try: 62 return self.__list 63 except AttributeError: 64 self.__list = self.split('\n') 65 return self.__list 66 67 l = list = property(get_list) 68 69 def get_spstr(self) -> str: 70 try: 71 return self.__spstr 72 except AttributeError: 73 self.__spstr = self.replace('\n',' ') 74 return self.__spstr 75 76 s = spstr = property(get_spstr) 77 78 def get_nlstr(self) -> Self: 79 return self 80 81 n = nlstr = property(get_nlstr) 82 83 def get_paths(self) -> List[Path]: 84 try: 85 return self.__paths 86 except AttributeError: 87 self.__paths = [Path(p) for p in self.split('\n') if os.path.exists(p)] 88 return self.__paths 89 90 p = paths = property(get_paths) 91 92 # FIXME: We need to reimplement type specific displayhook and then add this 93 # back as a custom printer. This should also be moved outside utils into the 94 # core. 95 96 # def print_lsstring(arg): 97 # """ Prettier (non-repr-like) and more informative printer for LSString """ 98 # print("LSString (.p, .n, .l, .s available). Value:") 99 # print(arg) 100 # 101 # 102 # print_lsstring = result_display.register(LSString)(print_lsstring) 103 104 105 class SList(list): 106 """List derivative with a special access attributes. 107 108 These are normal lists, but with the special attributes: 109 110 * .l (or .list) : value as list (the list itself). 111 * .n (or .nlstr): value as a string, joined on newlines. 112 * .s (or .spstr): value as a string, joined on spaces. 113 * .p (or .paths): list of path objects (requires path.py package) 114 115 Any values which require transformations are computed only once and 116 cached.""" 117 118 __spstr: str 119 __nlstr: str 120 __paths: List[Path] 121 122 def get_list(self) -> Self: 123 return self 124 125 l = list = property(get_list) 126 127 def get_spstr(self) -> str: 128 try: 129 return self.__spstr 130 except AttributeError: 131 self.__spstr = ' '.join(self) 132 return self.__spstr 133 134 s = spstr = property(get_spstr) 135 136 def get_nlstr(self) -> str: 137 try: 138 return self.__nlstr 139 except AttributeError: 140 self.__nlstr = '\n'.join(self) 141 return self.__nlstr 142 143 n = nlstr = property(get_nlstr) 144 145 def get_paths(self) -> List[Path]: 146 try: 147 return self.__paths 148 except AttributeError: 149 self.__paths = [Path(p) for p in self if os.path.exists(p)] 150 return self.__paths 151 152 p = paths = property(get_paths) 153 154 def grep( 155 self, 156 pattern: Union[str, Callable[[Any], re.Match[str] | None]], 157 prune: bool = False, 158 field: Optional[int] = None, 159 ) -> Self: 160 """Return all strings matching 'pattern' (a regex or callable) 161 162 This is case-insensitive. If prune is true, return all items 163 NOT matching the pattern. 164 165 If field is specified, the match must occur in the specified 166 whitespace-separated field. 167 168 Examples:: 169 170 a.grep( lambda x: x.startswith('C') ) 171 a.grep('Cha.*log', prune=1) 172 a.grep('chm', field=-1) 173 """ 174 175 def match_target(s: str) -> str: 176 if field is None: 177 return s 178 parts = s.split() 179 try: 180 tgt = parts[field] 181 return tgt 182 except IndexError: 183 return "" 184 185 if isinstance(pattern, str): 186 pred = lambda x : re.search(pattern, x, re.IGNORECASE) 187 else: 188 pred = pattern 189 if not prune: 190 return type(self)([el for el in self if pred(match_target(el))]) 191 else: 192 return type(self)([el for el in self if not pred(match_target(el))]) 193 194 def fields(self, *fields: List[str]) -> List[List[str]]: 195 """Collect whitespace-separated fields from string list 196 197 Allows quick awk-like usage of string lists. 198 199 Example data (in var a, created by 'a = !ls -l'):: 200 201 -rwxrwxrwx 1 ville None 18 Dec 14 2006 ChangeLog 202 drwxrwxrwx+ 6 ville None 0 Oct 24 18:05 IPython 203 204 * ``a.fields(0)`` is ``['-rwxrwxrwx', 'drwxrwxrwx+']`` 205 * ``a.fields(1,0)`` is ``['1 -rwxrwxrwx', '6 drwxrwxrwx+']`` 206 (note the joining by space). 207 * ``a.fields(-1)`` is ``['ChangeLog', 'IPython']`` 208 209 IndexErrors are ignored. 210 211 Without args, fields() just split()'s the strings. 212 """ 213 if len(fields) == 0: 214 return [el.split() for el in self] 215 216 res = SList() 217 for el in [f.split() for f in self]: 218 lineparts = [] 219 220 for fd in fields: 221 try: 222 lineparts.append(el[fd]) 223 except IndexError: 224 pass 225 if lineparts: 226 res.append(" ".join(lineparts)) 227 228 return res 229 230 def sort( # type:ignore[override] 231 self, 232 field: Optional[List[str]] = None, 233 nums: bool = False, 234 ) -> Self: 235 """sort by specified fields (see fields()) 236 237 Example:: 238 239 a.sort(1, nums = True) 240 241 Sorts a by second field, in numerical order (so that 21 > 3) 242 243 """ 244 245 #decorate, sort, undecorate 246 if field is not None: 247 dsu = [[SList([line]).fields(field), line] for line in self] 248 else: 249 dsu = [[line, line] for line in self] 250 if nums: 251 for i in range(len(dsu)): 252 numstr = "".join([ch for ch in dsu[i][0] if ch.isdigit()]) 253 try: 254 n = int(numstr) 255 except ValueError: 256 n = 0 257 dsu[i][0] = n 258 259 260 dsu.sort() 261 return type(self)([t[1] for t in dsu]) 262 263 264 # FIXME: We need to reimplement type specific displayhook and then add this 265 # back as a custom printer. This should also be moved outside utils into the 266 # core. 267 268 # def print_slist(arg): 269 # """ Prettier (non-repr-like) and more informative printer for SList """ 270 # print("SList (.p, .n, .l, .s, .grep(), .fields(), sort() available):") 271 # if hasattr(arg, 'hideonce') and arg.hideonce: 272 # arg.hideonce = False 273 # return 274 # 275 # nlprint(arg) # This was a nested list printer, now removed. 276 # 277 # print_slist = result_display.register(SList)(print_slist) 278 279 280 def indent(instr: str, nspaces: int = 4, ntabs: int = 0, flatten: bool = False) -> str: 281 """Indent a string a given number of spaces or tabstops. 282 283 indent(str,nspaces=4,ntabs=0) -> indent str by ntabs+nspaces. 284 285 Parameters 286 ---------- 287 instr : basestring 288 The string to be indented. 289 nspaces : int (default: 4) 290 The number of spaces to be indented. 291 ntabs : int (default: 0) 292 The number of tabs to be indented. 293 flatten : bool (default: False) 294 Whether to scrub existing indentation. If True, all lines will be 295 aligned to the same indentation. If False, existing indentation will 296 be strictly increased. 297 298 Returns 299 ------- 300 str : string indented by ntabs and nspaces. 301 302 """ 303 if instr is None: 304 return 305 ind = '\t'*ntabs+' '*nspaces 306 if flatten: 307 pat = re.compile(r'^\s*', re.MULTILINE) 308 else: 309 pat = re.compile(r'^', re.MULTILINE) 310 outstr = re.sub(pat, ind, instr) 311 if outstr.endswith(os.linesep+ind): 312 return outstr[:-len(ind)] 313 else: 314 return outstr 315 316 317 def list_strings(arg: Union[str, List[str]]) -> List[str]: 318 """Always return a list of strings, given a string or list of strings 319 as input. 320 321 Examples 322 -------- 323 :: 324 325 In [7]: list_strings('A single string') 326 Out[7]: ['A single string'] 327 328 In [8]: list_strings(['A single string in a list']) 329 Out[8]: ['A single string in a list'] 330 331 In [9]: list_strings(['A','list','of','strings']) 332 Out[9]: ['A', 'list', 'of', 'strings'] 333 """ 334 335 if isinstance(arg, str): 336 return [arg] 337 else: 338 return arg 339 340 341 def marquee(txt: str = "", width: int = 78, mark: str = "*") -> str: 342 """Return the input string centered in a 'marquee'. 343 344 Examples 345 -------- 346 :: 347 348 In [16]: marquee('A test',40) 349 Out[16]: '**************** A test ****************' 350 351 In [17]: marquee('A test',40,'-') 352 Out[17]: '---------------- A test ----------------' 353 354 In [18]: marquee('A test',40,' ') 355 Out[18]: ' A test ' 356 357 """ 358 if not txt: 359 return (mark*width)[:width] 360 nmark = (width-len(txt)-2)//len(mark)//2 361 if nmark < 0: nmark =0 362 marks = mark*nmark 363 return '%s %s %s' % (marks,txt,marks) 364 365 366 ini_spaces_re = re.compile(r'^(\s+)') 367 368 369 def num_ini_spaces(strng: str) -> int: 370 """Return the number of initial spaces in a string""" 371 warnings.warn( 372 "`num_ini_spaces` is Pending Deprecation since IPython 8.17." 373 "It is considered for removal in in future version. " 374 "Please open an issue if you believe it should be kept.", 375 stacklevel=2, 376 category=PendingDeprecationWarning, 377 ) 378 ini_spaces = ini_spaces_re.match(strng) 379 if ini_spaces: 380 return ini_spaces.end() 381 else: 382 return 0 383 384 385 def format_screen(strng: str) -> str: 386 """Format a string for screen printing. 387 388 This removes some latex-type format codes.""" 389 # Paragraph continue 390 par_re = re.compile(r'\\$',re.MULTILINE) 391 strng = par_re.sub('',strng) 392 return strng 393 394 395 def dedent(text: str) -> str: 396 """Equivalent of textwrap.dedent that ignores unindented first line. 397 398 This means it will still dedent strings like: 399 '''foo 400 is a bar 401 ''' 402 403 For use in wrap_paragraphs. 404 """ 405 406 if text.startswith('\n'): 407 # text starts with blank line, don't ignore the first line 408 return textwrap.dedent(text) 409 410 # split first line 411 splits = text.split('\n',1) 412 if len(splits) == 1: 413 # only one line 414 return textwrap.dedent(text) 415 416 first, rest = splits 417 # dedent everything but the first line 418 rest = textwrap.dedent(rest) 419 return '\n'.join([first, rest]) 420 421 422 def wrap_paragraphs(text: str, ncols: int = 80) -> List[str]: 423 """Wrap multiple paragraphs to fit a specified width. 424 425 This is equivalent to textwrap.wrap, but with support for multiple 426 paragraphs, as separated by empty lines. 427 428 Returns 429 ------- 430 list of complete paragraphs, wrapped to fill `ncols` columns. 431 """ 432 warnings.warn( 433 "`wrap_paragraphs` is Pending Deprecation since IPython 8.17." 434 "It is considered for removal in in future version. " 435 "Please open an issue if you believe it should be kept.", 436 stacklevel=2, 437 category=PendingDeprecationWarning, 438 ) 439 paragraph_re = re.compile(r'\n(\s*\n)+', re.MULTILINE) 440 text = dedent(text).strip() 441 paragraphs = paragraph_re.split(text)[::2] # every other entry is space 442 out_ps = [] 443 indent_re = re.compile(r'\n\s+', re.MULTILINE) 444 for p in paragraphs: 445 # presume indentation that survives dedent is meaningful formatting, 446 # so don't fill unless text is flush. 447 if indent_re.search(p) is None: 448 # wrap paragraph 449 p = textwrap.fill(p, ncols) 450 out_ps.append(p) 451 return out_ps 452 453 454 def strip_email_quotes(text: str) -> str: 455 """Strip leading email quotation characters ('>'). 456 457 Removes any combination of leading '>' interspersed with whitespace that 458 appears *identically* in all lines of the input text. 459 460 Parameters 461 ---------- 462 text : str 463 464 Examples 465 -------- 466 467 Simple uses:: 468 469 In [2]: strip_email_quotes('> > text') 470 Out[2]: 'text' 471 472 In [3]: strip_email_quotes('> > text\\n> > more') 473 Out[3]: 'text\\nmore' 474 475 Note how only the common prefix that appears in all lines is stripped:: 476 477 In [4]: strip_email_quotes('> > text\\n> > more\\n> more...') 478 Out[4]: '> text\\n> more\\nmore...' 479 480 So if any line has no quote marks ('>'), then none are stripped from any 481 of them :: 482 483 In [5]: strip_email_quotes('> > text\\n> > more\\nlast different') 484 Out[5]: '> > text\\n> > more\\nlast different' 485 """ 486 lines = text.splitlines() 487 strip_len = 0 488 489 for characters in zip(*lines): 490 # Check if all characters in this position are the same 491 if len(set(characters)) > 1: 492 break 493 prefix_char = characters[0] 494 495 if prefix_char in string.whitespace or prefix_char == ">": 496 strip_len += 1 497 else: 498 break 499 500 text = "\n".join([ln[strip_len:] for ln in lines]) 501 return text 502 503 504 def strip_ansi(source: str) -> str: 505 """ 506 Remove ansi escape codes from text. 507 508 Parameters 509 ---------- 510 source : str 511 Source to remove the ansi from 512 """ 513 warnings.warn( 514 "`strip_ansi` is Pending Deprecation since IPython 8.17." 515 "It is considered for removal in in future version. " 516 "Please open an issue if you believe it should be kept.", 517 stacklevel=2, 518 category=PendingDeprecationWarning, 519 ) 520 521 return re.sub(r'\033\[(\d|;)+?m', '', source) 522 523 524 class EvalFormatter(Formatter): 525 """A String Formatter that allows evaluation of simple expressions. 526 527 Note that this version interprets a `:` as specifying a format string (as per 528 standard string formatting), so if slicing is required, you must explicitly 529 create a slice. 530 531 This is to be used in templating cases, such as the parallel batch 532 script templates, where simple arithmetic on arguments is useful. 533 534 Examples 535 -------- 536 :: 537 538 In [1]: f = EvalFormatter() 539 In [2]: f.format('{n//4}', n=8) 540 Out[2]: '2' 541 542 In [3]: f.format("{greeting[slice(2,4)]}", greeting="Hello") 543 Out[3]: 'll' 544 """ 545 546 def get_field(self, name: str, args: Any, kwargs: Any) -> Tuple[Any, str]: 547 v = eval(name, kwargs) 548 return v, name 549 550 #XXX: As of Python 3.4, the format string parsing no longer splits on a colon 551 # inside [], so EvalFormatter can handle slicing. Once we only support 3.4 and 552 # above, it should be possible to remove FullEvalFormatter. 553 554 class FullEvalFormatter(Formatter): 555 """A String Formatter that allows evaluation of simple expressions. 556 557 Any time a format key is not found in the kwargs, 558 it will be tried as an expression in the kwargs namespace. 559 560 Note that this version allows slicing using [1:2], so you cannot specify 561 a format string. Use :class:`EvalFormatter` to permit format strings. 562 563 Examples 564 -------- 565 :: 566 567 In [1]: f = FullEvalFormatter() 568 In [2]: f.format('{n//4}', n=8) 569 Out[2]: '2' 570 571 In [3]: f.format('{list(range(5))[2:4]}') 572 Out[3]: '[2, 3]' 573 574 In [4]: f.format('{3*2}') 575 Out[4]: '6' 576 """ 577 # copied from Formatter._vformat with minor changes to allow eval 578 # and replace the format_spec code with slicing 579 def vformat( 580 self, format_string: str, args: Sequence[Any], kwargs: Mapping[str, Any] 581 ) -> str: 582 result = [] 583 conversion: Optional[str] 584 for literal_text, field_name, format_spec, conversion in self.parse( 585 format_string 586 ): 587 # output the literal text 588 if literal_text: 589 result.append(literal_text) 590 591 # if there's a field, output it 592 if field_name is not None: 593 # this is some markup, find the object and do 594 # the formatting 595 596 if format_spec: 597 # override format spec, to allow slicing: 598 field_name = ':'.join([field_name, format_spec]) 599 600 # eval the contents of the field for the object 601 # to be formatted 602 obj = eval(field_name, dict(kwargs)) 603 604 # do any conversion on the resulting object 605 # type issue in typeshed, fined in https://github.com/python/typeshed/pull/11377 606 obj = self.convert_field(obj, conversion) # type: ignore[arg-type] 607 608 # format the object and append to the result 609 result.append(self.format_field(obj, '')) 610 611 return ''.join(result) 612 613 614 class DollarFormatter(FullEvalFormatter): 615 """Formatter allowing Itpl style $foo replacement, for names and attribute 616 access only. Standard {foo} replacement also works, and allows full 617 evaluation of its arguments. 618 619 Examples 620 -------- 621 :: 622 623 In [1]: f = DollarFormatter() 624 In [2]: f.format('{n//4}', n=8) 625 Out[2]: '2' 626 627 In [3]: f.format('23 * 76 is $result', result=23*76) 628 Out[3]: '23 * 76 is 1748' 629 630 In [4]: f.format('$a or {b}', a=1, b=2) 631 Out[4]: '1 or 2' 632 """ 633 634 _dollar_pattern_ignore_single_quote = re.compile( 635 r"(.*?)\$(\$?[\w\.]+)(?=([^']*'[^']*')*[^']*$)" 636 ) 637 638 def parse(self, fmt_string: str) -> Iterator[Tuple[Any, Any, Any, Any]]: # type: ignore 639 for literal_txt, field_name, format_spec, conversion in Formatter.parse( 640 self, fmt_string 641 ): 642 # Find $foo patterns in the literal text. 643 continue_from = 0 644 txt = "" 645 for m in self._dollar_pattern_ignore_single_quote.finditer(literal_txt): 646 new_txt, new_field = m.group(1,2) 647 # $$foo --> $foo 648 if new_field.startswith("$"): 649 txt += new_txt + new_field 650 else: 651 yield (txt + new_txt, new_field, "", None) 652 txt = "" 653 continue_from = m.end() 654 655 # Re-yield the {foo} style pattern 656 yield (txt + literal_txt[continue_from:], field_name, format_spec, conversion) 657 658 def __repr__(self) -> str: 659 return "<DollarFormatter>" 660 661 #----------------------------------------------------------------------------- 662 # Utils to columnize a list of string 663 #----------------------------------------------------------------------------- 664 665 666 def _col_chunks( 667 l: List[int], max_rows: int, row_first: bool = False 668 ) -> Iterator[List[int]]: 669 """Yield successive max_rows-sized column chunks from l.""" 670 if row_first: 671 ncols = (len(l) // max_rows) + (len(l) % max_rows > 0) 672 for i in range(ncols): 673 yield [l[j] for j in range(i, len(l), ncols)] 674 else: 675 for i in range(0, len(l), max_rows): 676 yield l[i:(i + max_rows)] 677 678 679 def _find_optimal( 680 rlist: List[int], row_first: bool, separator_size: int, displaywidth: int 681 ) -> Dict[str, Any]: 682 """Calculate optimal info to columnize a list of string""" 683 for max_rows in range(1, len(rlist) + 1): 684 col_widths = list(map(max, _col_chunks(rlist, max_rows, row_first))) 685 sumlength = sum(col_widths) 686 ncols = len(col_widths) 687 if sumlength + separator_size * (ncols - 1) <= displaywidth: 688 break 689 return {'num_columns': ncols, 690 'optimal_separator_width': (displaywidth - sumlength) // (ncols - 1) if (ncols - 1) else 0, 691 'max_rows': max_rows, 692 'column_widths': col_widths 693 } 694 695 696 T = TypeVar("T") 697 698 699 def _get_or_default(mylist: List[T], i: int, default: T) -> T: 700 """return list item number, or default if don't exist""" 701 if i >= len(mylist): 702 return default 703 else : 704 return mylist[i] 705 706 707 def compute_item_matrix( 708 items: List[str], 709 row_first: bool = False, 710 empty: Optional[str] = None, 711 *, 712 separator_size: int = 2, 713 displaywidth: int = 80, 714 ) -> Tuple[List[List[int]], Dict[str, int]]: 715 """Returns a nested list, and info to columnize items 716 717 Parameters 718 ---------- 719 items 720 list of strings to columize 721 row_first : (default False) 722 Whether to compute columns for a row-first matrix instead of 723 column-first (default). 724 empty : (default None) 725 default value to fill list if needed 726 separator_size : int (default=2) 727 How much characters will be used as a separation between each columns. 728 displaywidth : int (default=80) 729 The width of the area onto which the columns should enter 730 731 Returns 732 ------- 733 strings_matrix 734 nested list of string, the outer most list contains as many list as 735 rows, the innermost lists have each as many element as columns. If the 736 total number of elements in `items` does not equal the product of 737 rows*columns, the last element of some lists are filled with `None`. 738 dict_info 739 some info to make columnize easier: 740 741 num_columns 742 number of columns 743 max_rows 744 maximum number of rows (final number may be less) 745 column_widths 746 list of with of each columns 747 optimal_separator_width 748 best separator width between columns 749 750 Examples 751 -------- 752 :: 753 754 In [1]: l = ['aaa','b','cc','d','eeeee','f','g','h','i','j','k','l'] 755 In [2]: list, info = compute_item_matrix(l, displaywidth=12) 756 In [3]: list 757 Out[3]: [['aaa', 'f', 'k'], ['b', 'g', 'l'], ['cc', 'h', None], ['d', 'i', None], ['eeeee', 'j', None]] 758 In [4]: ideal = {'num_columns': 3, 'column_widths': [5, 1, 1], 'optimal_separator_width': 2, 'max_rows': 5} 759 In [5]: all((info[k] == ideal[k] for k in ideal.keys())) 760 Out[5]: True 761 """ 762 warnings.warn( 763 "`compute_item_matrix` is Pending Deprecation since IPython 8.17." 764 "It is considered for removal in in future version. " 765 "Please open an issue if you believe it should be kept.", 766 stacklevel=2, 767 category=PendingDeprecationWarning, 768 ) 769 info = _find_optimal( 770 list(map(len, items)), # type: ignore[arg-type] 771 row_first, 772 separator_size=separator_size, 773 displaywidth=displaywidth, 774 ) 775 nrow, ncol = info["max_rows"], info["num_columns"] 776 if row_first: 777 return ( 778 [ 779 [ 780 _get_or_default( 781 items, r * ncol + c, default=empty 782 ) # type:ignore[misc] 783 for c in range(ncol) 784 ] 785 for r in range(nrow) 786 ], 787 info, 788 ) 789 else: 790 return ( 791 [ 792 [ 793 _get_or_default( 794 items, c * nrow + r, default=empty 795 ) # type:ignore[misc] 796 for c in range(ncol) 797 ] 798 for r in range(nrow) 799 ], 800 info, 801 ) 802 803 804 def columnize( 805 items: List[str], 806 row_first: bool = False, 807 separator: str = " ", 808 displaywidth: int = 80, 809 spread: bool = False, 810 ) -> str: 811 """Transform a list of strings into a single string with columns. 812 813 Parameters 814 ---------- 815 items : sequence of strings 816 The strings to process. 817 row_first : (default False) 818 Whether to compute columns for a row-first matrix instead of 819 column-first (default). 820 separator : str, optional [default is two spaces] 821 The string that separates columns. 822 displaywidth : int, optional [default is 80] 823 Width of the display in number of characters. 824 825 Returns 826 ------- 827 The formatted string. 828 """ 829 warnings.warn( 830 "`columnize` is Pending Deprecation since IPython 8.17." 831 "It is considered for removal in future versions. " 832 "Please open an issue if you believe it should be kept.", 833 stacklevel=2, 834 category=PendingDeprecationWarning, 835 ) 836 if not items: 837 return "\n" 838 matrix: List[List[int]] 839 matrix, info = compute_item_matrix( 840 items, 841 row_first=row_first, 842 separator_size=len(separator), 843 displaywidth=displaywidth, 844 ) 845 if spread: 846 separator = separator.ljust(int(info["optimal_separator_width"])) 847 fmatrix: List[filter[int]] = [filter(None, x) for x in matrix] 848 sjoin = lambda x: separator.join( 849 [y.ljust(w, " ") for y, w in zip(x, cast(List[int], info["column_widths"]))] 850 ) 851 return "\n".join(map(sjoin, fmatrix)) + "\n" 852 853 854 def get_text_list( 855 list_: List[str], last_sep: str = " and ", sep: str = ", ", wrap_item_with: str = "" 856 ) -> str: 857 """ 858 Return a string with a natural enumeration of items 859 860 >>> get_text_list(['a', 'b', 'c', 'd']) 861 'a, b, c and d' 862 >>> get_text_list(['a', 'b', 'c'], ' or ') 863 'a, b or c' 864 >>> get_text_list(['a', 'b', 'c'], ', ') 865 'a, b, c' 866 >>> get_text_list(['a', 'b'], ' or ') 867 'a or b' 868 >>> get_text_list(['a']) 869 'a' 870 >>> get_text_list([]) 871 '' 872 >>> get_text_list(['a', 'b'], wrap_item_with="`") 873 '`a` and `b`' 874 >>> get_text_list(['a', 'b', 'c', 'd'], " = ", sep=" + ") 875 'a + b + c = d' 876 """ 877 if len(list_) == 0: 878 return '' 879 if wrap_item_with: 880 list_ = ['%s%s%s' % (wrap_item_with, item, wrap_item_with) for 881 item in list_] 882 if len(list_) == 1: 883 return list_[0] 884 return '%s%s%s' % ( 885 sep.join(i for i in list_[:-1]), 886 last_sep, list_[-1]) 887