Adds shuffle/scramble to list_utils.
[python_utils.git] / string_utils.py
1 #!/usr/bin/env python3
2 # -*- coding: utf-8 -*-
3
4 """The MIT License (MIT)
5
6 Copyright (c) 2016-2020 Davide Zanotti
7 Modifications Copyright (c) 2021-2022 Scott Gasch
8
9 Permission is hereby granted, free of charge, to any person obtaining a copy
10 of this software and associated documentation files (the "Software"), to deal
11 in the Software without restriction, including without limitation the rights
12 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 copies of the Software, and to permit persons to whom the Software is
14 furnished to do so, subject to the following conditions:
15
16 The above copyright notice and this permission notice shall be included in all
17 copies or substantial portions of the Software.
18
19 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25 SOFTWARE.
26
27 This class is based on: https://github.com/daveoncode/python-string-utils.
28 """
29
30 import base64
31 import contextlib  # type: ignore
32 import datetime
33 import io
34 import json
35 import logging
36 import numbers
37 import random
38 import re
39 import string
40 import unicodedata
41 import warnings
42 from itertools import zip_longest
43 from typing import (
44     Any,
45     Callable,
46     Dict,
47     Iterable,
48     List,
49     Literal,
50     Optional,
51     Sequence,
52     Tuple,
53 )
54 from uuid import uuid4
55
56 import list_utils
57
58 logger = logging.getLogger(__name__)
59
60 NUMBER_RE = re.compile(r"^([+\-]?)((\d+)(\.\d+)?([e|E]\d+)?|\.\d+)$")
61
62 HEX_NUMBER_RE = re.compile(r"^([+|-]?)0[x|X]([0-9A-Fa-f]+)$")
63
64 OCT_NUMBER_RE = re.compile(r"^([+|-]?)0[O|o]([0-7]+)$")
65
66 BIN_NUMBER_RE = re.compile(r"^([+|-]?)0[B|b]([0|1]+)$")
67
68 URLS_RAW_STRING = (
69     r"([a-z-]+://)"  # scheme
70     r"([a-z_\d-]+:[a-z_\d-]+@)?"  # user:password
71     r"(www\.)?"  # www.
72     r"((?<!\.)[a-z\d]+[a-z\d.-]+\.[a-z]{2,6}|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|localhost)"  # domain
73     r"(:\d{2,})?"  # port number
74     r"(/[a-z\d_%+-]*)*"  # folders
75     r"(\.[a-z\d_%+-]+)*"  # file extension
76     r"(\?[a-z\d_+%-=]*)?"  # query string
77     r"(#\S*)?"  # hash
78 )
79
80 URL_RE = re.compile(r"^{}$".format(URLS_RAW_STRING), re.IGNORECASE)
81
82 URLS_RE = re.compile(r"({})".format(URLS_RAW_STRING), re.IGNORECASE)
83
84 ESCAPED_AT_SIGN = re.compile(r'(?!"[^"]*)@+(?=[^"]*")|\\@')
85
86 EMAILS_RAW_STRING = r"[a-zA-Z\d._\+\-'`!%#$&*/=\?\^\{\}\|~\\]+@[a-z\d-]+\.?[a-z\d-]+\.[a-z]{2,4}"
87
88 EMAIL_RE = re.compile(r"^{}$".format(EMAILS_RAW_STRING))
89
90 EMAILS_RE = re.compile(r"({})".format(EMAILS_RAW_STRING))
91
92 CAMEL_CASE_TEST_RE = re.compile(r"^[a-zA-Z]*([a-z]+[A-Z]+|[A-Z]+[a-z]+)[a-zA-Z\d]*$")
93
94 CAMEL_CASE_REPLACE_RE = re.compile(r"([a-z]|[A-Z]+)(?=[A-Z])")
95
96 SNAKE_CASE_TEST_RE = re.compile(r"^([a-z]+\d*_[a-z\d_]*|_+[a-z\d]+[a-z\d_]*)$", re.IGNORECASE)
97
98 SNAKE_CASE_TEST_DASH_RE = re.compile(r"([a-z]+\d*-[a-z\d-]*|-+[a-z\d]+[a-z\d-]*)$", re.IGNORECASE)
99
100 SNAKE_CASE_REPLACE_RE = re.compile(r"(_)([a-z\d])")
101
102 SNAKE_CASE_REPLACE_DASH_RE = re.compile(r"(-)([a-z\d])")
103
104 CREDIT_CARDS = {
105     "VISA": re.compile(r"^4\d{12}(?:\d{3})?$"),
106     "MASTERCARD": re.compile(r"^5[1-5]\d{14}$"),
107     "AMERICAN_EXPRESS": re.compile(r"^3[47]\d{13}$"),
108     "DINERS_CLUB": re.compile(r"^3(?:0[0-5]|[68]\d)\d{11}$"),
109     "DISCOVER": re.compile(r"^6(?:011|5\d{2})\d{12}$"),
110     "JCB": re.compile(r"^(?:2131|1800|35\d{3})\d{11}$"),
111 }
112
113 JSON_WRAPPER_RE = re.compile(r"^\s*[\[{]\s*(.*)\s*[\}\]]\s*$", re.MULTILINE | re.DOTALL)
114
115 UUID_RE = re.compile(r"^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$", re.IGNORECASE)
116
117 UUID_HEX_OK_RE = re.compile(
118     r"^[a-f\d]{8}-?[a-f\d]{4}-?[a-f\d]{4}-?[a-f\d]{4}-?[a-f\d]{12}$",
119     re.IGNORECASE,
120 )
121
122 SHALLOW_IP_V4_RE = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
123
124 ANYWHERE_IP_V4_RE = re.compile(r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}")
125
126 IP_V6_RE = re.compile(r"^([a-z\d]{0,4}:){7}[a-z\d]{0,4}$", re.IGNORECASE)
127
128 ANYWHERE_IP_V6_RE = re.compile(r"([a-z\d]{0,4}:){7}[a-z\d]{0,4}", re.IGNORECASE)
129
130 MAC_ADDRESS_RE = re.compile(r"^([0-9A-F]{2}[:-]){5}([0-9A-F]{2})$", re.IGNORECASE)
131
132 ANYWHERE_MAC_ADDRESS_RE = re.compile(r"([0-9A-F]{2}[:-]){5}([0-9A-F]{2})", re.IGNORECASE)
133
134 WORDS_COUNT_RE = re.compile(r"\W*[^\W_]+\W*", re.IGNORECASE | re.MULTILINE | re.UNICODE)
135
136 HTML_RE = re.compile(
137     r"((<([a-z]+:)?[a-z]+[^>]*/?>)(.*?(</([a-z]+:)?[a-z]+>))?|<!--.*-->|<!doctype.*>)",
138     re.IGNORECASE | re.MULTILINE | re.DOTALL,
139 )
140
141 HTML_TAG_ONLY_RE = re.compile(
142     r"(<([a-z]+:)?[a-z]+[^>]*/?>|</([a-z]+:)?[a-z]+>|<!--.*-->|<!doctype.*>)",
143     re.IGNORECASE | re.MULTILINE | re.DOTALL,
144 )
145
146 SPACES_RE = re.compile(r"\s")
147
148 NO_LETTERS_OR_NUMBERS_RE = re.compile(r"[^\w\d]+|_+", re.IGNORECASE | re.UNICODE)
149
150 MARGIN_RE = re.compile(r"^[^\S\r\n]+")
151
152 ESCAPE_SEQUENCE_RE = re.compile(r"\e\[[^A-Za-z]*[A-Za-z]")
153
154 NUM_SUFFIXES = {
155     "Pb": (1024**5),
156     "P": (1024**5),
157     "Tb": (1024**4),
158     "T": (1024**4),
159     "Gb": (1024**3),
160     "G": (1024**3),
161     "Mb": (1024**2),
162     "M": (1024**2),
163     "Kb": (1024**1),
164     "K": (1024**1),
165 }
166
167
168 def is_none_or_empty(in_str: Optional[str]) -> bool:
169     """
170     Returns true if the input string is either None or an empty string.
171
172     >>> is_none_or_empty("")
173     True
174     >>> is_none_or_empty(None)
175     True
176     >>> is_none_or_empty("   \t   ")
177     True
178     >>> is_none_or_empty('Test')
179     False
180     """
181     return in_str is None or len(in_str.strip()) == 0
182
183
184 def is_string(obj: Any) -> bool:
185     """
186     Checks if an object is a string.
187
188     >>> is_string('test')
189     True
190     >>> is_string(123)
191     False
192     >>> is_string(100.3)
193     False
194     >>> is_string([1, 2, 3])
195     False
196     """
197     return isinstance(obj, str)
198
199
200 def is_empty_string(in_str: Any) -> bool:
201     return is_empty(in_str)
202
203
204 def is_empty(in_str: Any) -> bool:
205     """
206     Checks if input is a string and empty or only whitespace.
207
208     >>> is_empty('')
209     True
210     >>> is_empty('    \t\t    ')
211     True
212     >>> is_empty('test')
213     False
214     >>> is_empty(100.88)
215     False
216     >>> is_empty([1, 2, 3])
217     False
218     """
219     return is_string(in_str) and in_str.strip() == ""
220
221
222 def is_full_string(in_str: Any) -> bool:
223     """
224     Checks that input is a string and is not empty ('') or only whitespace.
225
226     >>> is_full_string('test!')
227     True
228     >>> is_full_string('')
229     False
230     >>> is_full_string('      ')
231     False
232     >>> is_full_string(100.999)
233     False
234     >>> is_full_string({"a": 1, "b": 2})
235     False
236     """
237     return is_string(in_str) and in_str.strip() != ""
238
239
240 def is_number(in_str: str) -> bool:
241     """
242     Checks if a string is a valid number.
243
244     >>> is_number(100.5)
245     Traceback (most recent call last):
246     ...
247     ValueError: 100.5
248     >>> is_number("100.5")
249     True
250     >>> is_number("test")
251     False
252     >>> is_number("99")
253     True
254     >>> is_number([1, 2, 3])
255     Traceback (most recent call last):
256     ...
257     ValueError: [1, 2, 3]
258     """
259     if not is_string(in_str):
260         raise ValueError(in_str)
261     return NUMBER_RE.match(in_str) is not None
262
263
264 def is_integer_number(in_str: str) -> bool:
265     """
266     Checks whether the given string represents an integer or not.
267
268     An integer may be signed or unsigned or use a "scientific notation".
269
270     >>> is_integer_number('42')
271     True
272     >>> is_integer_number('42.0')
273     False
274     """
275     return (
276         (is_number(in_str) and "." not in in_str)
277         or is_hexidecimal_integer_number(in_str)
278         or is_octal_integer_number(in_str)
279         or is_binary_integer_number(in_str)
280     )
281
282
283 def is_hexidecimal_integer_number(in_str: str) -> bool:
284     """
285     Checks whether a string is a hex integer number.
286
287     >>> is_hexidecimal_integer_number('0x12345')
288     True
289     >>> is_hexidecimal_integer_number('0x1A3E')
290     True
291     >>> is_hexidecimal_integer_number('1234')  # Needs 0x
292     False
293     >>> is_hexidecimal_integer_number('-0xff')
294     True
295     >>> is_hexidecimal_integer_number('test')
296     False
297     >>> is_hexidecimal_integer_number(12345)  # Not a string
298     Traceback (most recent call last):
299     ...
300     ValueError: 12345
301     >>> is_hexidecimal_integer_number(101.4)
302     Traceback (most recent call last):
303     ...
304     ValueError: 101.4
305     >>> is_hexidecimal_integer_number(0x1A3E)
306     Traceback (most recent call last):
307     ...
308     ValueError: 6718
309     """
310     if not is_string(in_str):
311         raise ValueError(in_str)
312     return HEX_NUMBER_RE.match(in_str) is not None
313
314
315 def is_octal_integer_number(in_str: str) -> bool:
316     """
317     Checks whether a string is an octal number.
318
319     >>> is_octal_integer_number('0o777')
320     True
321     >>> is_octal_integer_number('-0O115')
322     True
323     >>> is_octal_integer_number('0xFF')  # Not octal, needs 0o
324     False
325     >>> is_octal_integer_number('7777')  # Needs 0o
326     False
327     >>> is_octal_integer_number('test')
328     False
329     """
330     if not is_string(in_str):
331         raise ValueError(in_str)
332     return OCT_NUMBER_RE.match(in_str) is not None
333
334
335 def is_binary_integer_number(in_str: str) -> bool:
336     """
337     Returns whether a string contains a binary number.
338
339     >>> is_binary_integer_number('0b10111')
340     True
341     >>> is_binary_integer_number('-0b111')
342     True
343     >>> is_binary_integer_number('0B10101')
344     True
345     >>> is_binary_integer_number('0b10102')
346     False
347     >>> is_binary_integer_number('0xFFF')
348     False
349     >>> is_binary_integer_number('test')
350     False
351     """
352     if not is_string(in_str):
353         raise ValueError(in_str)
354     return BIN_NUMBER_RE.match(in_str) is not None
355
356
357 def to_int(in_str: str) -> int:
358     """Returns the integral value of the string or raises on error.
359
360     >>> to_int('1234')
361     1234
362     >>> to_int('test')
363     Traceback (most recent call last):
364     ...
365     ValueError: invalid literal for int() with base 10: 'test'
366     """
367     if not is_string(in_str):
368         raise ValueError(in_str)
369     if is_binary_integer_number(in_str):
370         return int(in_str, 2)
371     if is_octal_integer_number(in_str):
372         return int(in_str, 8)
373     if is_hexidecimal_integer_number(in_str):
374         return int(in_str, 16)
375     return int(in_str)
376
377
378 def is_decimal_number(in_str: str) -> bool:
379     """
380     Checks whether the given string represents a decimal or not.
381
382     A decimal may be signed or unsigned or use a "scientific notation".
383
384     >>> is_decimal_number('42.0')
385     True
386     >>> is_decimal_number('42')
387     False
388     """
389     return is_number(in_str) and "." in in_str
390
391
392 def strip_escape_sequences(in_str: str) -> str:
393     """
394     Remove escape sequences in the input string.
395
396     >>> strip_escape_sequences('\e[12;11;22mthis is a test!')
397     'this is a test!'
398     """
399     in_str = ESCAPE_SEQUENCE_RE.sub("", in_str)
400     return in_str
401
402
403 def add_thousands_separator(in_str: str, *, separator_char=',', places=3) -> str:
404     """
405     Add thousands separator to a numeric string.  Also handles numbers.
406
407     >>> add_thousands_separator('12345678')
408     '12,345,678'
409     >>> add_thousands_separator(12345678)
410     '12,345,678'
411     >>> add_thousands_separator(12345678.99)
412     '12,345,678.99'
413     >>> add_thousands_separator('test')
414     Traceback (most recent call last):
415     ...
416     ValueError: test
417
418     """
419     if isinstance(in_str, numbers.Number):
420         in_str = f'{in_str}'
421     if is_number(in_str):
422         return _add_thousands_separator(in_str, separator_char=separator_char, places=places)
423     raise ValueError(in_str)
424
425
426 def _add_thousands_separator(in_str: str, *, separator_char=',', places=3) -> str:
427     decimal_part = ""
428     if '.' in in_str:
429         (in_str, decimal_part) = in_str.split('.')
430     tmp = [iter(in_str[::-1])] * places
431     ret = separator_char.join("".join(x) for x in zip_longest(*tmp, fillvalue=""))[::-1]
432     if len(decimal_part) > 0:
433         ret += '.'
434         ret += decimal_part
435     return ret
436
437
438 # Full url example:
439 # scheme://username:[email protected]:8042/folder/subfolder/file.extension?param=value&param2=value2#hash
440 def is_url(in_str: Any, allowed_schemes: Optional[List[str]] = None) -> bool:
441     """
442     Check if a string is a valid url.
443
444     >>> is_url('http://www.mysite.com')
445     True
446     >>> is_url('https://mysite.com')
447     True
448     >>> is_url('.mysite.com')
449     False
450     """
451     if not is_full_string(in_str):
452         return False
453
454     valid = URL_RE.match(in_str) is not None
455
456     if allowed_schemes:
457         return valid and any([in_str.startswith(s) for s in allowed_schemes])
458     return valid
459
460
461 def is_email(in_str: Any) -> bool:
462     """
463     Check if a string is a valid email.
464
465     Reference: https://tools.ietf.org/html/rfc3696#section-3
466
467     >>> is_email('[email protected]')
468     True
469     >>> is_email('@gmail.com')
470     False
471     """
472     if not is_full_string(in_str) or len(in_str) > 320 or in_str.startswith("."):
473         return False
474
475     try:
476         # we expect 2 tokens, one before "@" and one after, otherwise
477         # we have an exception and the email is not valid.
478         head, tail = in_str.split("@")
479
480         # head's size must be <= 64, tail <= 255, head must not start
481         # with a dot or contain multiple consecutive dots.
482         if len(head) > 64 or len(tail) > 255 or head.endswith(".") or (".." in head):
483             return False
484
485         # removes escaped spaces, so that later on the test regex will
486         # accept the string.
487         head = head.replace("\\ ", "")
488         if head.startswith('"') and head.endswith('"'):
489             head = head.replace(" ", "")[1:-1]
490         return EMAIL_RE.match(head + "@" + tail) is not None
491
492     except ValueError:
493         # borderline case in which we have multiple "@" signs but the
494         # head part is correctly escaped.
495         if ESCAPED_AT_SIGN.search(in_str) is not None:
496             # replace "@" with "a" in the head
497             return is_email(ESCAPED_AT_SIGN.sub("a", in_str))
498         return False
499
500
501 def suffix_string_to_number(in_str: str) -> Optional[int]:
502     """Take a string like "33Gb" and convert it into a number (of bytes)
503     like 34603008.  Return None if the input string is not valid.
504
505     >>> suffix_string_to_number('1Mb')
506     1048576
507     >>> suffix_string_to_number('13.1Gb')
508     14066017894
509     """
510
511     def suffix_capitalize(s: str) -> str:
512         if len(s) == 1:
513             return s.upper()
514         elif len(s) == 2:
515             return f"{s[0].upper()}{s[1].lower()}"
516         return suffix_capitalize(s[0:1])
517
518     if is_string(in_str):
519         if is_integer_number(in_str):
520             return to_int(in_str)
521         suffixes = [in_str[-2:], in_str[-1:]]
522         rest = [in_str[:-2], in_str[:-1]]
523         for x in range(len(suffixes)):
524             s = suffixes[x]
525             s = suffix_capitalize(s)
526             multiplier = NUM_SUFFIXES.get(s, None)
527             if multiplier is not None:
528                 r = rest[x]
529                 if is_integer_number(r):
530                     return to_int(r) * multiplier
531                 if is_decimal_number(r):
532                     return int(float(r) * multiplier)
533     return None
534
535
536 def number_to_suffix_string(num: int) -> Optional[str]:
537     """Take a number (of bytes) and returns a string like "43.8Gb".
538     Returns none if the input is invalid.
539
540     >>> number_to_suffix_string(14066017894)
541     '13.1Gb'
542     >>> number_to_suffix_string(1024 * 1024)
543     '1.0Mb'
544
545     """
546     d = 0.0
547     suffix = None
548     for (sfx, size) in NUM_SUFFIXES.items():
549         if num >= size:
550             d = num / size
551             suffix = sfx
552             break
553     if suffix is not None:
554         return f"{d:.1f}{suffix}"
555     else:
556         return f'{num:d}'
557
558
559 def is_credit_card(in_str: Any, card_type: str = None) -> bool:
560     """
561     Checks if a string is a valid credit card number.
562     If card type is provided then it checks against that specific type only,
563     otherwise any known credit card number will be accepted.
564
565     Supported card types are the following:
566
567     - VISA
568     - MASTERCARD
569     - AMERICAN_EXPRESS
570     - DINERS_CLUB
571     - DISCOVER
572     - JCB
573     """
574     if not is_full_string(in_str):
575         return False
576
577     if card_type is not None:
578         if card_type not in CREDIT_CARDS:
579             raise KeyError(
580                 f'Invalid card type "{card_type}". Valid types are: {CREDIT_CARDS.keys()}'
581             )
582         return CREDIT_CARDS[card_type].match(in_str) is not None
583     for c in CREDIT_CARDS:
584         if CREDIT_CARDS[c].match(in_str) is not None:
585             return True
586     return False
587
588
589 def is_camel_case(in_str: Any) -> bool:
590     """
591     Checks if a string is formatted as camel case.
592
593     A string is considered camel case when:
594
595     - it's composed only by letters ([a-zA-Z]) and optionally numbers ([0-9])
596     - it contains both lowercase and uppercase letters
597     - it does not start with a number
598     """
599     return is_full_string(in_str) and CAMEL_CASE_TEST_RE.match(in_str) is not None
600
601
602 def is_snake_case(in_str: Any, *, separator: str = "_") -> bool:
603     """
604     Checks if a string is formatted as "snake case".
605
606     A string is considered snake case when:
607
608     - it's composed only by lowercase/uppercase letters and digits
609     - it contains at least one underscore (or provided separator)
610     - it does not start with a number
611
612     >>> is_snake_case('this_is_a_test')
613     True
614     >>> is_snake_case('___This_Is_A_Test_1_2_3___')
615     True
616     >>> is_snake_case('this-is-a-test')
617     False
618     >>> is_snake_case('this-is-a-test', separator='-')
619     True
620
621     """
622     if is_full_string(in_str):
623         re_map = {"_": SNAKE_CASE_TEST_RE, "-": SNAKE_CASE_TEST_DASH_RE}
624         re_template = r"([a-z]+\d*{sign}[a-z\d{sign}]*|{sign}+[a-z\d]+[a-z\d{sign}]*)"
625         r = re_map.get(
626             separator,
627             re.compile(re_template.format(sign=re.escape(separator)), re.IGNORECASE),
628         )
629         return r.match(in_str) is not None
630     return False
631
632
633 def is_json(in_str: Any) -> bool:
634     """
635     Check if a string is a valid json.
636
637     >>> is_json('{"name": "Peter"}')
638     True
639     >>> is_json('[1, 2, 3]')
640     True
641     >>> is_json('{nope}')
642     False
643     """
644     if is_full_string(in_str) and JSON_WRAPPER_RE.match(in_str) is not None:
645         try:
646             return isinstance(json.loads(in_str), (dict, list))
647         except (TypeError, ValueError, OverflowError):
648             pass
649     return False
650
651
652 def is_uuid(in_str: Any, allow_hex: bool = False) -> bool:
653     """
654     Check if a string is a valid UUID.
655
656     >>> is_uuid('6f8aa2f9-686c-4ac3-8766-5712354a04cf')
657     True
658     >>> is_uuid('6f8aa2f9686c4ac387665712354a04cf')
659     False
660     >>> is_uuid('6f8aa2f9686c4ac387665712354a04cf', allow_hex=True)
661     True
662     """
663     # string casting is used to allow UUID itself as input data type
664     s = str(in_str)
665     if allow_hex:
666         return UUID_HEX_OK_RE.match(s) is not None
667     return UUID_RE.match(s) is not None
668
669
670 def is_ip_v4(in_str: Any) -> bool:
671     """
672     Checks if a string is a valid ip v4.
673
674     >>> is_ip_v4('255.200.100.75')
675     True
676     >>> is_ip_v4('nope')
677     False
678     >>> is_ip_v4('255.200.100.999')  # 999 out of range
679     False
680     """
681     if not is_full_string(in_str) or SHALLOW_IP_V4_RE.match(in_str) is None:
682         return False
683
684     # checks that each entry in the ip is in the valid range (0 to 255)
685     for token in in_str.split("."):
686         if not 0 <= int(token) <= 255:
687             return False
688     return True
689
690
691 def extract_ip_v4(in_str: Any) -> Optional[str]:
692     """
693     Extracts the IPv4 chunk of a string or None.
694
695     >>> extract_ip_v4('   The secret IP address: 127.0.0.1 (use it wisely)   ')
696     '127.0.0.1'
697     >>> extract_ip_v4('Your mom dresses you funny.')
698     """
699     if not is_full_string(in_str):
700         return None
701     m = ANYWHERE_IP_V4_RE.search(in_str)
702     if m is not None:
703         return m.group(0)
704     return None
705
706
707 def is_ip_v6(in_str: Any) -> bool:
708     """
709     Checks if a string is a valid ip v6.
710
711     >>> is_ip_v6('2001:db8:85a3:0000:0000:8a2e:370:7334')
712     True
713     >>> is_ip_v6('2001:db8:85a3:0000:0000:8a2e:370:?')    # invalid "?"
714     False
715     """
716     return is_full_string(in_str) and IP_V6_RE.match(in_str) is not None
717
718
719 def extract_ip_v6(in_str: Any) -> Optional[str]:
720     """
721     Extract IPv6 chunk or None.
722
723     >>> extract_ip_v6('IP: 2001:db8:85a3:0000:0000:8a2e:370:7334')
724     '2001:db8:85a3:0000:0000:8a2e:370:7334'
725     >>> extract_ip_v6("(and she's ugly too, btw)")
726     """
727     if not is_full_string(in_str):
728         return None
729     m = ANYWHERE_IP_V6_RE.search(in_str)
730     if m is not None:
731         return m.group(0)
732     return None
733
734
735 def is_ip(in_str: Any) -> bool:
736     """
737     Checks if a string is a valid ip (either v4 or v6).
738
739     >>> is_ip('255.200.100.75')
740     True
741     >>> is_ip('2001:db8:85a3:0000:0000:8a2e:370:7334')
742     True
743     >>> is_ip('1.2.3')
744     False
745     >>> is_ip('1.2.3.999')
746     False
747     """
748     return is_ip_v6(in_str) or is_ip_v4(in_str)
749
750
751 def extract_ip(in_str: Any) -> Optional[str]:
752     """
753     Extract the IP address or None.
754
755     >>> extract_ip('Attacker: 255.200.100.75')
756     '255.200.100.75'
757     >>> extract_ip('Remote host: 2001:db8:85a3:0000:0000:8a2e:370:7334')
758     '2001:db8:85a3:0000:0000:8a2e:370:7334'
759     >>> extract_ip('1.2.3')
760
761     """
762     ip = extract_ip_v4(in_str)
763     if ip is None:
764         ip = extract_ip_v6(in_str)
765     return ip
766
767
768 def is_mac_address(in_str: Any) -> bool:
769     """Return True if in_str is a valid MAC address false otherwise.
770
771     >>> is_mac_address("34:29:8F:12:0D:2F")
772     True
773     >>> is_mac_address('34:29:8f:12:0d:2f')
774     True
775     >>> is_mac_address('34-29-8F-12-0D-2F')
776     True
777     >>> is_mac_address("test")
778     False
779     """
780     return is_full_string(in_str) and MAC_ADDRESS_RE.match(in_str) is not None
781
782
783 def extract_mac_address(in_str: Any, *, separator: str = ":") -> Optional[str]:
784     """
785     Extract the MAC address from in_str.
786
787     >>> extract_mac_address(' MAC Address: 34:29:8F:12:0D:2F')
788     '34:29:8F:12:0D:2F'
789
790     >>> extract_mac_address('? (10.0.0.30) at d8:5d:e2:34:54:86 on em0 expires in 1176 seconds [ethernet]')
791     'd8:5d:e2:34:54:86'
792
793     """
794     if not is_full_string(in_str):
795         return None
796     in_str.strip()
797     m = ANYWHERE_MAC_ADDRESS_RE.search(in_str)
798     if m is not None:
799         mac = m.group(0)
800         mac.replace(":", separator)
801         mac.replace("-", separator)
802         return mac
803     return None
804
805
806 def is_slug(in_str: Any, separator: str = "-") -> bool:
807     """
808     Checks if a given string is a slug (as created by `slugify()`).
809
810     >>> is_slug('my-blog-post-title')
811     True
812     >>> is_slug('My blog post title')
813     False
814
815     """
816     if not is_full_string(in_str):
817         return False
818     rex = r"^([a-z\d]+" + re.escape(separator) + r"*?)*[a-z\d]$"
819     return re.match(rex, in_str) is not None
820
821
822 def contains_html(in_str: str) -> bool:
823     """
824     Checks if the given string contains HTML/XML tags.
825
826     By design, this function matches ANY type of tag, so don't expect to use it
827     as an HTML validator, its goal is to detect "malicious" or undesired tags in the text.
828
829     >>> contains_html('my string is <strong>bold</strong>')
830     True
831     >>> contains_html('my string is not bold')
832     False
833
834     """
835     if not is_string(in_str):
836         raise ValueError(in_str)
837     return HTML_RE.search(in_str) is not None
838
839
840 def words_count(in_str: str) -> int:
841     """
842     Returns the number of words contained into the given string.
843
844     This method is smart, it does consider only sequence of one or more letter and/or numbers
845     as "words", so a string like this: "! @ # % ... []" will return zero!
846     Moreover it is aware of punctuation, so the count for a string like "one,two,three.stop"
847     will be 4 not 1 (even if there are no spaces in the string).
848
849     >>> words_count('hello world')
850     2
851     >>> words_count('one,two,three.stop')
852     4
853
854     """
855     if not is_string(in_str):
856         raise ValueError(in_str)
857     return len(WORDS_COUNT_RE.findall(in_str))
858
859
860 def generate_uuid(omit_dashes: bool = False) -> str:
861     """
862     Generated an UUID string (using `uuid.uuid4()`).
863
864     generate_uuid() # possible output: '97e3a716-6b33-4ab9-9bb1-8128cb24d76b'
865     generate_uuid(omit_dashes=True) # possible output: '97e3a7166b334ab99bb18128cb24d76b'
866
867     """
868     uid = uuid4()
869     if omit_dashes:
870         return uid.hex
871     return str(uid)
872
873
874 def generate_random_alphanumeric_string(size: int) -> str:
875     """
876     Returns a string of the specified size containing random
877     characters (uppercase/lowercase ascii letters and digits).
878
879     random_string(9) # possible output: "cx3QQbzYg"
880
881     """
882     if size < 1:
883         raise ValueError("size must be >= 1")
884     chars = string.ascii_letters + string.digits
885     buffer = [random.choice(chars) for _ in range(size)]
886     return from_char_list(buffer)
887
888
889 def reverse(in_str: str) -> str:
890     """
891     Returns the string with its chars reversed.
892
893     >>> reverse('test')
894     'tset'
895
896     """
897     if not is_string(in_str):
898         raise ValueError(in_str)
899     return in_str[::-1]
900
901
902 def camel_case_to_snake_case(in_str, *, separator="_"):
903     """
904     Convert a camel case string into a snake case one.
905     (The original string is returned if is not a valid camel case string)
906
907     >>> camel_case_to_snake_case('MacAddressExtractorFactory')
908     'mac_address_extractor_factory'
909     >>> camel_case_to_snake_case('Luke Skywalker')
910     'Luke Skywalker'
911     """
912     if not is_string(in_str):
913         raise ValueError(in_str)
914     if not is_camel_case(in_str):
915         return in_str
916     return CAMEL_CASE_REPLACE_RE.sub(lambda m: m.group(1) + separator, in_str).lower()
917
918
919 def snake_case_to_camel_case(
920     in_str: str, *, upper_case_first: bool = True, separator: str = "_"
921 ) -> str:
922     """
923     Convert a snake case string into a camel case one.
924     (The original string is returned if is not a valid snake case string)
925
926     >>> snake_case_to_camel_case('this_is_a_test')
927     'ThisIsATest'
928     >>> snake_case_to_camel_case('Han Solo')
929     'Han Solo'
930     """
931     if not is_string(in_str):
932         raise ValueError(in_str)
933     if not is_snake_case(in_str, separator=separator):
934         return in_str
935     tokens = [s.title() for s in in_str.split(separator) if is_full_string(s)]
936     if not upper_case_first:
937         tokens[0] = tokens[0].lower()
938     return from_char_list(tokens)
939
940
941 def to_char_list(in_str: str) -> List[str]:
942     """Convert a string into a list of chars.
943
944     >>> to_char_list('test')
945     ['t', 'e', 's', 't']
946     """
947     if not is_string(in_str):
948         return []
949     return list(in_str)
950
951
952 def from_char_list(in_list: List[str]) -> str:
953     """Convert a char list into a string.
954
955     >>> from_char_list(['t', 'e', 's', 't'])
956     'test'
957     """
958     return "".join(in_list)
959
960
961 def shuffle(in_str: str) -> str:
962     """Return a new string containing same chars of the given one but in
963     a randomized order.
964     """
965     if not is_string(in_str):
966         raise ValueError(in_str)
967
968     # turn the string into a list of chars
969     chars = to_char_list(in_str)
970     random.shuffle(chars)
971     return from_char_list(chars)
972
973
974 def scramble(in_str: str) -> str:
975     return shuffle(in_str)
976
977
978 def strip_html(in_str: str, keep_tag_content: bool = False) -> str:
979     """
980     Remove html code contained into the given string.
981
982     >>> strip_html('test: <a href="foo/bar">click here</a>')
983     'test: '
984     >>> strip_html('test: <a href="foo/bar">click here</a>', keep_tag_content=True)
985     'test: click here'
986     """
987     if not is_string(in_str):
988         raise ValueError(in_str)
989     r = HTML_TAG_ONLY_RE if keep_tag_content else HTML_RE
990     return r.sub("", in_str)
991
992
993 def asciify(in_str: str) -> str:
994     """
995     Force string content to be ascii-only by translating all non-ascii
996     chars into the closest possible representation (eg: ó -> o, Ë ->
997     E, ç -> c...).
998
999     N.B. Some chars may be lost if impossible to translate.
1000
1001     >>> asciify('èéùúòóäåëýñÅÀÁÇÌÍÑÓË')
1002     'eeuuooaaeynAAACIINOE'
1003     """
1004     if not is_string(in_str):
1005         raise ValueError(in_str)
1006
1007     # "NFKD" is the algorithm which is able to successfully translate
1008     # the most of non-ascii chars.
1009     normalized = unicodedata.normalize("NFKD", in_str)
1010
1011     # encode string forcing ascii and ignore any errors
1012     # (unrepresentable chars will be stripped out)
1013     ascii_bytes = normalized.encode("ascii", "ignore")
1014
1015     # turns encoded bytes into an utf-8 string
1016     return ascii_bytes.decode("utf-8")
1017
1018
1019 def slugify(in_str: str, *, separator: str = "-") -> str:
1020     """
1021     Converts a string into a "slug" using provided separator.
1022     The returned string has the following properties:
1023
1024     - it has no spaces
1025     - all letters are in lower case
1026     - all punctuation signs and non alphanumeric chars are removed
1027     - words are divided using provided separator
1028     - all chars are encoded as ascii (by using `asciify()`)
1029     - is safe for URL
1030
1031     >>> slugify('Top 10 Reasons To Love Dogs!!!')
1032     'top-10-reasons-to-love-dogs'
1033     >>> slugify('Mönstér Mägnët')
1034     'monster-magnet'
1035     """
1036     if not is_string(in_str):
1037         raise ValueError(in_str)
1038
1039     # replace any character that is NOT letter or number with spaces
1040     out = NO_LETTERS_OR_NUMBERS_RE.sub(" ", in_str.lower()).strip()
1041
1042     # replace spaces with join sign
1043     out = SPACES_RE.sub(separator, out)
1044
1045     # normalize joins (remove duplicates)
1046     out = re.sub(re.escape(separator) + r"+", separator, out)
1047     return asciify(out)
1048
1049
1050 def to_bool(in_str: str) -> bool:
1051     """
1052     Turns a string into a boolean based on its content (CASE INSENSITIVE).
1053
1054     A positive boolean (True) is returned if the string value is one
1055     of the following:
1056
1057     - "true"
1058     - "1"
1059     - "yes"
1060     - "y"
1061
1062     Otherwise False is returned.
1063
1064     >>> to_bool('True')
1065     True
1066
1067     >>> to_bool('1')
1068     True
1069
1070     >>> to_bool('yes')
1071     True
1072
1073     >>> to_bool('no')
1074     False
1075
1076     >>> to_bool('huh?')
1077     False
1078
1079     >>> to_bool('on')
1080     True
1081
1082     """
1083     if not is_string(in_str):
1084         raise ValueError(in_str)
1085     return in_str.lower() in ("true", "1", "yes", "y", "t", "on")
1086
1087
1088 def to_date(in_str: str) -> Optional[datetime.date]:
1089     """
1090     Parses a date string.  See DateParser docs for details.
1091     """
1092     import dateparse.dateparse_utils as du
1093
1094     try:
1095         d = du.DateParser()  # type: ignore
1096         d.parse(in_str)
1097         return d.get_date()
1098     except du.ParseException:  # type: ignore
1099         msg = f'Unable to parse date {in_str}.'
1100         logger.warning(msg)
1101     return None
1102
1103
1104 def valid_date(in_str: str) -> bool:
1105     """
1106     True if the string represents a valid date.
1107     """
1108     import dateparse.dateparse_utils as dp
1109
1110     try:
1111         d = dp.DateParser()  # type: ignore
1112         _ = d.parse(in_str)
1113         return True
1114     except dp.ParseException:  # type: ignore
1115         msg = f'Unable to parse date {in_str}.'
1116         logger.warning(msg)
1117     return False
1118
1119
1120 def to_datetime(in_str: str) -> Optional[datetime.datetime]:
1121     """
1122     Parses a datetime string.  See DateParser docs for more info.
1123     """
1124     import dateparse.dateparse_utils as dp
1125
1126     try:
1127         d = dp.DateParser()  # type: ignore
1128         dt = d.parse(in_str)
1129         if isinstance(dt, datetime.datetime):
1130             return dt
1131     except ValueError:
1132         msg = f'Unable to parse datetime {in_str}.'
1133         logger.warning(msg)
1134     return None
1135
1136
1137 def valid_datetime(in_str: str) -> bool:
1138     """
1139     True if the string represents a valid datetime.
1140     """
1141     _ = to_datetime(in_str)
1142     if _ is not None:
1143         return True
1144     msg = f'Unable to parse datetime {in_str}.'
1145     logger.warning(msg)
1146     return False
1147
1148
1149 def squeeze(in_str: str, character_to_squeeze: str = ' ') -> str:
1150     """
1151     Squeeze runs of more than one character_to_squeeze into one.
1152
1153     >>> squeeze(' this        is       a    test    ')
1154     ' this is a test '
1155
1156     >>> squeeze('one|!||!|two|!||!|three', character_to_squeeze='|!|')
1157     'one|!|two|!|three'
1158
1159     """
1160     return re.sub(
1161         r'(' + re.escape(character_to_squeeze) + r')+',
1162         character_to_squeeze,
1163         in_str,
1164     )
1165
1166
1167 def dedent(in_str: str) -> str:
1168     """
1169     Removes tab indentation from multi line strings (inspired by analogous Scala function).
1170     """
1171     if not is_string(in_str):
1172         raise ValueError(in_str)
1173     line_separator = '\n'
1174     lines = [MARGIN_RE.sub('', line) for line in in_str.split(line_separator)]
1175     return line_separator.join(lines)
1176
1177
1178 def indent(in_str: str, amount: int) -> str:
1179     """
1180     Indents string by prepending amount spaces.
1181
1182     >>> indent('This is a test', 4)
1183     '    This is a test'
1184
1185     """
1186     if not is_string(in_str):
1187         raise ValueError(in_str)
1188     line_separator = '\n'
1189     lines = [" " * amount + line for line in in_str.split(line_separator)]
1190     return line_separator.join(lines)
1191
1192
1193 def sprintf(*args, **kwargs) -> str:
1194     """String printf, like in C"""
1195     ret = ""
1196
1197     sep = kwargs.pop("sep", None)
1198     if sep is not None:
1199         if not isinstance(sep, str):
1200             raise TypeError("sep must be None or a string")
1201
1202     end = kwargs.pop("end", None)
1203     if end is not None:
1204         if not isinstance(end, str):
1205             raise TypeError("end must be None or a string")
1206
1207     if kwargs:
1208         raise TypeError("invalid keyword arguments to sprint()")
1209
1210     if sep is None:
1211         sep = " "
1212     if end is None:
1213         end = "\n"
1214     for i, arg in enumerate(args):
1215         if i:
1216             ret += sep
1217         if isinstance(arg, str):
1218             ret += arg
1219         else:
1220             ret += str(arg)
1221     ret += end
1222     return ret
1223
1224
1225 def strip_ansi_sequences(in_str: str) -> str:
1226     """Strips ANSI sequences out of strings.
1227
1228     >>> import ansi as a
1229     >>> s = a.fg('blue') + 'blue!' + a.reset()
1230     >>> len(s)   # '\x1b[38;5;21mblue!\x1b[m'
1231     18
1232     >>> len(strip_ansi_sequences(s))
1233     5
1234     >>> strip_ansi_sequences(s)
1235     'blue!'
1236
1237     """
1238     return re.sub(r'\x1b\[[\d+;]*[a-z]', '', in_str)
1239
1240
1241 class SprintfStdout(contextlib.AbstractContextManager):
1242     """
1243     A context manager that captures outputs to stdout.
1244
1245     with SprintfStdout() as buf:
1246         print("test")
1247     print(buf())
1248
1249     'test\n'
1250     """
1251
1252     def __init__(self) -> None:
1253         self.destination = io.StringIO()
1254         self.recorder: contextlib.redirect_stdout
1255
1256     def __enter__(self) -> Callable[[], str]:
1257         self.recorder = contextlib.redirect_stdout(self.destination)
1258         self.recorder.__enter__()
1259         return lambda: self.destination.getvalue()
1260
1261     def __exit__(self, *args) -> Literal[False]:
1262         self.recorder.__exit__(*args)
1263         self.destination.seek(0)
1264         return False
1265
1266
1267 def capitalize_first_letter(txt: str) -> str:
1268     """Capitalize the first letter of a string.
1269
1270     >>> capitalize_first_letter('test')
1271     'Test'
1272     >>> capitalize_first_letter("ALREADY!")
1273     'ALREADY!'
1274
1275     """
1276     return txt[0].upper() + txt[1:]
1277
1278
1279 def it_they(n: int) -> str:
1280     """It or they?
1281
1282     >>> it_they(1)
1283     'it'
1284     >>> it_they(100)
1285     'they'
1286
1287     """
1288     if n == 1:
1289         return "it"
1290     return "they"
1291
1292
1293 def is_are(n: int) -> str:
1294     """Is or are?
1295
1296     >>> is_are(1)
1297     'is'
1298     >>> is_are(2)
1299     'are'
1300
1301     """
1302     if n == 1:
1303         return "is"
1304     return "are"
1305
1306
1307 def pluralize(n: int) -> str:
1308     """Add an s?
1309
1310     >>> pluralize(15)
1311     's'
1312     >>> count = 1
1313     >>> print(f'There {is_are(count)} {count} file{pluralize(count)}.')
1314     There is 1 file.
1315     >>> count = 4
1316     >>> print(f'There {is_are(count)} {count} file{pluralize(count)}.')
1317     There are 4 files.
1318
1319     """
1320     if n == 1:
1321         return ""
1322     return "s"
1323
1324
1325 def make_contractions(txt: str) -> str:
1326     """Glue words together to form contractions.
1327
1328     >>> make_contractions('It is nice today.')
1329     "It's nice today."
1330
1331     >>> make_contractions('I can    not even...')
1332     "I can't even..."
1333
1334     >>> make_contractions('She could not see!')
1335     "She couldn't see!"
1336
1337     >>> make_contractions('But she will not go.')
1338     "But she won't go."
1339
1340     >>> make_contractions('Verily, I shall not.')
1341     "Verily, I shan't."
1342
1343     >>> make_contractions('No you cannot.')
1344     "No you can't."
1345
1346     >>> make_contractions('I said you can not go.')
1347     "I said you can't go."
1348
1349     """
1350
1351     first_second = [
1352         (
1353             [
1354                 'are',
1355                 'could',
1356                 'did',
1357                 'has',
1358                 'have',
1359                 'is',
1360                 'must',
1361                 'should',
1362                 'was',
1363                 'were',
1364                 'would',
1365             ],
1366             ['(n)o(t)'],
1367         ),
1368         (
1369             [
1370                 "I",
1371                 "you",
1372                 "he",
1373                 "she",
1374                 "it",
1375                 "we",
1376                 "they",
1377                 "how",
1378                 "why",
1379                 "when",
1380                 "where",
1381                 "who",
1382                 "there",
1383             ],
1384             ['woul(d)', 'i(s)', 'a(re)', 'ha(s)', 'ha(ve)', 'ha(d)', 'wi(ll)'],
1385         ),
1386     ]
1387
1388     # Special cases: can't, shan't and won't.
1389     txt = re.sub(r'\b(can)\s*no(t)\b', r"\1'\2", txt, count=0, flags=re.IGNORECASE)
1390     txt = re.sub(r'\b(sha)ll\s*(n)o(t)\b', r"\1\2'\3", txt, count=0, flags=re.IGNORECASE)
1391     txt = re.sub(
1392         r'\b(w)ill\s*(n)(o)(t)\b',
1393         r"\1\3\2'\4",
1394         txt,
1395         count=0,
1396         flags=re.IGNORECASE,
1397     )
1398
1399     for first_list, second_list in first_second:
1400         for first in first_list:
1401             for second in second_list:
1402                 # Disallow there're/where're.  They're valid English
1403                 # but sound weird.
1404                 if (first in ('there', 'where')) and second == 'a(re)':
1405                     continue
1406
1407                 pattern = fr'\b({first})\s+{second}\b'
1408                 if second == '(n)o(t)':
1409                     replacement = r"\1\2'\3"
1410                 else:
1411                     replacement = r"\1'\2"
1412                 txt = re.sub(pattern, replacement, txt, count=0, flags=re.IGNORECASE)
1413
1414     return txt
1415
1416
1417 def thify(n: int) -> str:
1418     """Return the proper cardinal suffix for a number.
1419
1420     >>> thify(1)
1421     'st'
1422     >>> thify(33)
1423     'rd'
1424     >>> thify(16)
1425     'th'
1426
1427     """
1428     digit = str(n)
1429     assert is_integer_number(digit)
1430     digit = digit[-1:]
1431     if digit == "1":
1432         return "st"
1433     elif digit == "2":
1434         return "nd"
1435     elif digit == "3":
1436         return "rd"
1437     else:
1438         return "th"
1439
1440
1441 def ngrams(txt: str, n: int):
1442     """Return the ngrams from a string.
1443
1444     >>> [x for x in ngrams('This is a test', 2)]
1445     ['This is', 'is a', 'a test']
1446
1447     """
1448     words = txt.split()
1449     for ngram in ngrams_presplit(words, n):
1450         ret = ''
1451         for word in ngram:
1452             ret += f'{word} '
1453         yield ret.strip()
1454
1455
1456 def ngrams_presplit(words: Sequence[str], n: int):
1457     return list_utils.ngrams(words, n)
1458
1459
1460 def bigrams(txt: str):
1461     return ngrams(txt, 2)
1462
1463
1464 def trigrams(txt: str):
1465     return ngrams(txt, 3)
1466
1467
1468 def shuffle_columns_into_list(
1469     input_lines: Sequence[str], column_specs: Iterable[Iterable[int]], delim=''
1470 ) -> Iterable[str]:
1471     """Helper to shuffle / parse columnar data and return the results as a
1472     list.  The column_specs argument is an iterable collection of
1473     numeric sequences that indicate one or more column numbers to
1474     copy.
1475
1476     >>> cols = '-rwxr-xr-x 1 scott wheel 3.1K Jul  9 11:34 acl_test.py'.split()
1477     >>> shuffle_columns_into_list(
1478     ...     cols,
1479     ...     [ [8], [2, 3], [5, 6, 7] ],
1480     ...     delim=' ',
1481     ... )
1482     ['acl_test.py', 'scott wheel', 'Jul 9 11:34']
1483
1484     """
1485     out = []
1486
1487     # Column specs map input lines' columns into outputs.
1488     # [col1, col2...]
1489     for spec in column_specs:
1490         hunk = ''
1491         for n in spec:
1492             hunk = hunk + delim + input_lines[n]
1493         hunk = hunk.strip(delim)
1494         out.append(hunk)
1495     return out
1496
1497
1498 def shuffle_columns_into_dict(
1499     input_lines: Sequence[str],
1500     column_specs: Iterable[Tuple[str, Iterable[int]]],
1501     delim='',
1502 ) -> Dict[str, str]:
1503     """Helper to shuffle / parse columnar data and return the results
1504     as a dict.
1505
1506     >>> cols = '-rwxr-xr-x 1 scott wheel 3.1K Jul  9 11:34 acl_test.py'.split()
1507     >>> shuffle_columns_into_dict(
1508     ...     cols,
1509     ...     [ ('filename', [8]), ('owner', [2, 3]), ('mtime', [5, 6, 7]) ],
1510     ...     delim=' ',
1511     ... )
1512     {'filename': 'acl_test.py', 'owner': 'scott wheel', 'mtime': 'Jul 9 11:34'}
1513
1514     """
1515     out = {}
1516
1517     # Column specs map input lines' columns into outputs.
1518     # "key", [col1, col2...]
1519     for spec in column_specs:
1520         hunk = ''
1521         for n in spec[1]:
1522             hunk = hunk + delim + input_lines[n]
1523         hunk = hunk.strip(delim)
1524         out[spec[0]] = hunk
1525     return out
1526
1527
1528 def interpolate_using_dict(txt: str, values: Dict[str, str]) -> str:
1529     """Interpolate a string with data from a dict.
1530
1531     >>> interpolate_using_dict('This is a {adjective} {noun}.',
1532     ...                        {'adjective': 'good', 'noun': 'example'})
1533     'This is a good example.'
1534
1535     """
1536     return sprintf(txt.format(**values), end='')
1537
1538
1539 def to_ascii(x: str):
1540     """Encode as ascii bytes string.
1541
1542     >>> to_ascii('test')
1543     b'test'
1544
1545     >>> to_ascii(b'1, 2, 3')
1546     b'1, 2, 3'
1547
1548     """
1549     if isinstance(x, str):
1550         return x.encode('ascii')
1551     if isinstance(x, bytes):
1552         return x
1553     raise Exception('to_ascii works with strings and bytes')
1554
1555
1556 def to_base64(txt: str, *, encoding='utf-8', errors='surrogatepass') -> bytes:
1557     """Encode txt and then encode the bytes with a 64-character
1558     alphabet.  This is compatible with uudecode.
1559
1560     >>> to_base64('hello?')
1561     b'aGVsbG8/\\n'
1562
1563     """
1564     return base64.encodebytes(txt.encode(encoding, errors))
1565
1566
1567 def is_base64(txt: str) -> bool:
1568     """Determine whether a string is base64 encoded (with Python's standard
1569     base64 alphabet which is the same as what uuencode uses).
1570
1571     >>> is_base64('test')    # all letters in the b64 alphabet
1572     True
1573
1574     >>> is_base64('another test, how do you like this one?')
1575     False
1576
1577     >>> is_base64(b'aGVsbG8/\\n')    # Ending newline is ok.
1578     True
1579
1580     """
1581     a = string.ascii_uppercase + string.ascii_lowercase + string.digits + '+/'
1582     alphabet = set(a.encode('ascii'))
1583     for char in to_ascii(txt.strip()):
1584         if char not in alphabet:
1585             return False
1586     return True
1587
1588
1589 def from_base64(b64: bytes, encoding='utf-8', errors='surrogatepass') -> str:
1590     """Convert base64 encoded string back to normal strings.
1591
1592     >>> from_base64(b'aGVsbG8/\\n')
1593     'hello?'
1594
1595     """
1596     return base64.decodebytes(b64).decode(encoding, errors)
1597
1598
1599 def chunk(txt: str, chunk_size):
1600     """Chunk up a string.
1601
1602     >>> ' '.join(chunk('010011011100010110101010101010101001111110101000', 8))
1603     '01001101 11000101 10101010 10101010 10011111 10101000'
1604
1605     """
1606     if len(txt) % chunk_size != 0:
1607         msg = f'String to chunk\'s length ({len(txt)} is not an even multiple of chunk_size ({chunk_size})'
1608         logger.warning(msg)
1609         warnings.warn(msg, stacklevel=2)
1610     for x in range(0, len(txt), chunk_size):
1611         yield txt[x : x + chunk_size]
1612
1613
1614 def to_bitstring(txt: str, *, delimiter='', encoding='utf-8', errors='surrogatepass') -> str:
1615     """Encode txt and then chop it into bytes.  Note: only bitstrings
1616     with delimiter='' are interpretable by from_bitstring.
1617
1618     >>> to_bitstring('hello?')
1619     '011010000110010101101100011011000110111100111111'
1620
1621     >>> to_bitstring('test', delimiter=' ')
1622     '01110100 01100101 01110011 01110100'
1623
1624     >>> to_bitstring(b'test')
1625     '01110100011001010111001101110100'
1626
1627     """
1628     etxt = to_ascii(txt)
1629     bits = bin(int.from_bytes(etxt, 'big'))
1630     bits = bits[2:]
1631     return delimiter.join(chunk(bits.zfill(8 * ((len(bits) + 7) // 8)), 8))
1632
1633
1634 def is_bitstring(txt: str) -> bool:
1635     """Is this a bitstring?
1636
1637     >>> is_bitstring('011010000110010101101100011011000110111100111111')
1638     True
1639
1640     >>> is_bitstring('1234')
1641     False
1642
1643     """
1644     return is_binary_integer_number(f'0b{txt}')
1645
1646
1647 def from_bitstring(bits: str, encoding='utf-8', errors='surrogatepass') -> str:
1648     """Convert from bitstring back to bytes then decode into a str.
1649
1650     >>> from_bitstring('011010000110010101101100011011000110111100111111')
1651     'hello?'
1652
1653     """
1654     n = int(bits, 2)
1655     return n.to_bytes((n.bit_length() + 7) // 8, 'big').decode(encoding, errors) or '\0'
1656
1657
1658 def ip_v4_sort_key(txt: str) -> Optional[Tuple[int, ...]]:
1659     """Turn an IPv4 address into a tuple for sorting purposes.
1660
1661     >>> ip_v4_sort_key('10.0.0.18')
1662     (10, 0, 0, 18)
1663
1664     >>> ips = ['10.0.0.10', '100.0.0.1', '1.2.3.4', '10.0.0.9']
1665     >>> sorted(ips, key=lambda x: ip_v4_sort_key(x))
1666     ['1.2.3.4', '10.0.0.9', '10.0.0.10', '100.0.0.1']
1667
1668     """
1669     if not is_ip_v4(txt):
1670         print(f"not IP: {txt}")
1671         return None
1672     return tuple(int(x) for x in txt.split('.'))
1673
1674
1675 def path_ancestors_before_descendants_sort_key(volume: str) -> Tuple[str, ...]:
1676     """Chunk up a file path so that parent/ancestor paths sort before
1677     children/descendant paths.
1678
1679     >>> path_ancestors_before_descendants_sort_key('/usr/local/bin')
1680     ('usr', 'local', 'bin')
1681
1682     >>> paths = ['/usr/local', '/usr/local/bin', '/usr']
1683     >>> sorted(paths, key=lambda x: path_ancestors_before_descendants_sort_key(x))
1684     ['/usr', '/usr/local', '/usr/local/bin']
1685
1686     """
1687     return tuple(x for x in volume.split('/') if len(x) > 0)
1688
1689
1690 def replace_all(in_str: str, replace_set: str, replacement: str) -> str:
1691     """Execute several replace operations in a row.
1692
1693     >>> s = 'this_is a-test!'
1694     >>> replace_all(s, ' _-!', '')
1695     'thisisatest'
1696
1697     """
1698     for char in replace_set:
1699         in_str = in_str.replace(char, replacement)
1700     return in_str
1701
1702
1703 def replace_nth(in_str: str, source: str, target: str, nth: int):
1704     """Replaces the nth occurrance of a substring within a string.
1705
1706     >>> replace_nth('this is a test', ' ', '-', 3)
1707     'this is a-test'
1708
1709     """
1710     where = [m.start() for m in re.finditer(source, in_str)][nth - 1]
1711     before = in_str[:where]
1712     after = in_str[where:]
1713     after = after.replace(source, target, 1)
1714     return before + after
1715
1716
1717 if __name__ == '__main__':
1718     import doctest
1719
1720     doctest.testmod()