More cleanup.
[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 strip_html(in_str: str, keep_tag_content: bool = False) -> str:
975     """
976     Remove html code contained into the given string.
977
978     >>> strip_html('test: <a href="foo/bar">click here</a>')
979     'test: '
980     >>> strip_html('test: <a href="foo/bar">click here</a>', keep_tag_content=True)
981     'test: click here'
982     """
983     if not is_string(in_str):
984         raise ValueError(in_str)
985     r = HTML_TAG_ONLY_RE if keep_tag_content else HTML_RE
986     return r.sub("", in_str)
987
988
989 def asciify(in_str: str) -> str:
990     """
991     Force string content to be ascii-only by translating all non-ascii
992     chars into the closest possible representation (eg: ó -> o, Ë ->
993     E, ç -> c...).
994
995     N.B. Some chars may be lost if impossible to translate.
996
997     >>> asciify('èéùúòóäåëýñÅÀÁÇÌÍÑÓË')
998     'eeuuooaaeynAAACIINOE'
999     """
1000     if not is_string(in_str):
1001         raise ValueError(in_str)
1002
1003     # "NFKD" is the algorithm which is able to successfully translate
1004     # the most of non-ascii chars.
1005     normalized = unicodedata.normalize("NFKD", in_str)
1006
1007     # encode string forcing ascii and ignore any errors
1008     # (unrepresentable chars will be stripped out)
1009     ascii_bytes = normalized.encode("ascii", "ignore")
1010
1011     # turns encoded bytes into an utf-8 string
1012     return ascii_bytes.decode("utf-8")
1013
1014
1015 def slugify(in_str: str, *, separator: str = "-") -> str:
1016     """
1017     Converts a string into a "slug" using provided separator.
1018     The returned string has the following properties:
1019
1020     - it has no spaces
1021     - all letters are in lower case
1022     - all punctuation signs and non alphanumeric chars are removed
1023     - words are divided using provided separator
1024     - all chars are encoded as ascii (by using `asciify()`)
1025     - is safe for URL
1026
1027     >>> slugify('Top 10 Reasons To Love Dogs!!!')
1028     'top-10-reasons-to-love-dogs'
1029     >>> slugify('Mönstér Mägnët')
1030     'monster-magnet'
1031     """
1032     if not is_string(in_str):
1033         raise ValueError(in_str)
1034
1035     # replace any character that is NOT letter or number with spaces
1036     out = NO_LETTERS_OR_NUMBERS_RE.sub(" ", in_str.lower()).strip()
1037
1038     # replace spaces with join sign
1039     out = SPACES_RE.sub(separator, out)
1040
1041     # normalize joins (remove duplicates)
1042     out = re.sub(re.escape(separator) + r"+", separator, out)
1043     return asciify(out)
1044
1045
1046 def to_bool(in_str: str) -> bool:
1047     """
1048     Turns a string into a boolean based on its content (CASE INSENSITIVE).
1049
1050     A positive boolean (True) is returned if the string value is one
1051     of the following:
1052
1053     - "true"
1054     - "1"
1055     - "yes"
1056     - "y"
1057
1058     Otherwise False is returned.
1059
1060     >>> to_bool('True')
1061     True
1062
1063     >>> to_bool('1')
1064     True
1065
1066     >>> to_bool('yes')
1067     True
1068
1069     >>> to_bool('no')
1070     False
1071
1072     >>> to_bool('huh?')
1073     False
1074
1075     >>> to_bool('on')
1076     True
1077
1078     """
1079     if not is_string(in_str):
1080         raise ValueError(in_str)
1081     return in_str.lower() in ("true", "1", "yes", "y", "t", "on")
1082
1083
1084 def to_date(in_str: str) -> Optional[datetime.date]:
1085     """
1086     Parses a date string.  See DateParser docs for details.
1087     """
1088     import dateparse.dateparse_utils as du
1089
1090     try:
1091         d = du.DateParser()  # type: ignore
1092         d.parse(in_str)
1093         return d.get_date()
1094     except du.ParseException:  # type: ignore
1095         msg = f'Unable to parse date {in_str}.'
1096         logger.warning(msg)
1097     return None
1098
1099
1100 def valid_date(in_str: str) -> bool:
1101     """
1102     True if the string represents a valid date.
1103     """
1104     import dateparse.dateparse_utils as dp
1105
1106     try:
1107         d = dp.DateParser()  # type: ignore
1108         _ = d.parse(in_str)
1109         return True
1110     except dp.ParseException:  # type: ignore
1111         msg = f'Unable to parse date {in_str}.'
1112         logger.warning(msg)
1113     return False
1114
1115
1116 def to_datetime(in_str: str) -> Optional[datetime.datetime]:
1117     """
1118     Parses a datetime string.  See DateParser docs for more info.
1119     """
1120     import dateparse.dateparse_utils as dp
1121
1122     try:
1123         d = dp.DateParser()  # type: ignore
1124         dt = d.parse(in_str)
1125         if isinstance(dt, datetime.datetime):
1126             return dt
1127     except ValueError:
1128         msg = f'Unable to parse datetime {in_str}.'
1129         logger.warning(msg)
1130     return None
1131
1132
1133 def valid_datetime(in_str: str) -> bool:
1134     """
1135     True if the string represents a valid datetime.
1136     """
1137     _ = to_datetime(in_str)
1138     if _ is not None:
1139         return True
1140     msg = f'Unable to parse datetime {in_str}.'
1141     logger.warning(msg)
1142     return False
1143
1144
1145 def squeeze(in_str: str, character_to_squeeze: str = ' ') -> str:
1146     """
1147     Squeeze runs of more than one character_to_squeeze into one.
1148
1149     >>> squeeze(' this        is       a    test    ')
1150     ' this is a test '
1151
1152     >>> squeeze('one|!||!|two|!||!|three', character_to_squeeze='|!|')
1153     'one|!|two|!|three'
1154
1155     """
1156     return re.sub(
1157         r'(' + re.escape(character_to_squeeze) + r')+',
1158         character_to_squeeze,
1159         in_str,
1160     )
1161
1162
1163 def dedent(in_str: str) -> str:
1164     """
1165     Removes tab indentation from multi line strings (inspired by analogous Scala function).
1166     """
1167     if not is_string(in_str):
1168         raise ValueError(in_str)
1169     line_separator = '\n'
1170     lines = [MARGIN_RE.sub('', line) for line in in_str.split(line_separator)]
1171     return line_separator.join(lines)
1172
1173
1174 def indent(in_str: str, amount: int) -> str:
1175     """
1176     Indents string by prepending amount spaces.
1177
1178     >>> indent('This is a test', 4)
1179     '    This is a test'
1180
1181     """
1182     if not is_string(in_str):
1183         raise ValueError(in_str)
1184     line_separator = '\n'
1185     lines = [" " * amount + line for line in in_str.split(line_separator)]
1186     return line_separator.join(lines)
1187
1188
1189 def sprintf(*args, **kwargs) -> str:
1190     """String printf, like in C"""
1191     ret = ""
1192
1193     sep = kwargs.pop("sep", None)
1194     if sep is not None:
1195         if not isinstance(sep, str):
1196             raise TypeError("sep must be None or a string")
1197
1198     end = kwargs.pop("end", None)
1199     if end is not None:
1200         if not isinstance(end, str):
1201             raise TypeError("end must be None or a string")
1202
1203     if kwargs:
1204         raise TypeError("invalid keyword arguments to sprint()")
1205
1206     if sep is None:
1207         sep = " "
1208     if end is None:
1209         end = "\n"
1210     for i, arg in enumerate(args):
1211         if i:
1212             ret += sep
1213         if isinstance(arg, str):
1214             ret += arg
1215         else:
1216             ret += str(arg)
1217     ret += end
1218     return ret
1219
1220
1221 class SprintfStdout(contextlib.AbstractContextManager):
1222     """
1223     A context manager that captures outputs to stdout.
1224
1225     with SprintfStdout() as buf:
1226         print("test")
1227     print(buf())
1228
1229     'test\n'
1230     """
1231
1232     def __init__(self) -> None:
1233         self.destination = io.StringIO()
1234         self.recorder: contextlib.redirect_stdout
1235
1236     def __enter__(self) -> Callable[[], str]:
1237         self.recorder = contextlib.redirect_stdout(self.destination)
1238         self.recorder.__enter__()
1239         return lambda: self.destination.getvalue()
1240
1241     def __exit__(self, *args) -> Literal[False]:
1242         self.recorder.__exit__(*args)
1243         self.destination.seek(0)
1244         return False
1245
1246
1247 def capitalize_first_letter(txt: str) -> str:
1248     """Capitalize the first letter of a string.
1249
1250     >>> capitalize_first_letter('test')
1251     'Test'
1252     >>> capitalize_first_letter("ALREADY!")
1253     'ALREADY!'
1254
1255     """
1256     return txt[0].upper() + txt[1:]
1257
1258
1259 def it_they(n: int) -> str:
1260     """It or they?
1261
1262     >>> it_they(1)
1263     'it'
1264     >>> it_they(100)
1265     'they'
1266
1267     """
1268     if n == 1:
1269         return "it"
1270     return "they"
1271
1272
1273 def is_are(n: int) -> str:
1274     """Is or are?
1275
1276     >>> is_are(1)
1277     'is'
1278     >>> is_are(2)
1279     'are'
1280
1281     """
1282     if n == 1:
1283         return "is"
1284     return "are"
1285
1286
1287 def pluralize(n: int) -> str:
1288     """Add an s?
1289
1290     >>> pluralize(15)
1291     's'
1292     >>> count = 1
1293     >>> print(f'There {is_are(count)} {count} file{pluralize(count)}.')
1294     There is 1 file.
1295     >>> count = 4
1296     >>> print(f'There {is_are(count)} {count} file{pluralize(count)}.')
1297     There are 4 files.
1298
1299     """
1300     if n == 1:
1301         return ""
1302     return "s"
1303
1304
1305 def make_contractions(txt: str) -> str:
1306     """Glue words together to form contractions.
1307
1308     >>> make_contractions('It is nice today.')
1309     "It's nice today."
1310
1311     >>> make_contractions('I can    not even...')
1312     "I can't even..."
1313
1314     >>> make_contractions('She could not see!')
1315     "She couldn't see!"
1316
1317     >>> make_contractions('But she will not go.')
1318     "But she won't go."
1319
1320     >>> make_contractions('Verily, I shall not.')
1321     "Verily, I shan't."
1322
1323     >>> make_contractions('No you cannot.')
1324     "No you can't."
1325
1326     >>> make_contractions('I said you can not go.')
1327     "I said you can't go."
1328
1329     """
1330
1331     first_second = [
1332         (
1333             [
1334                 'are',
1335                 'could',
1336                 'did',
1337                 'has',
1338                 'have',
1339                 'is',
1340                 'must',
1341                 'should',
1342                 'was',
1343                 'were',
1344                 'would',
1345             ],
1346             ['(n)o(t)'],
1347         ),
1348         (
1349             [
1350                 "I",
1351                 "you",
1352                 "he",
1353                 "she",
1354                 "it",
1355                 "we",
1356                 "they",
1357                 "how",
1358                 "why",
1359                 "when",
1360                 "where",
1361                 "who",
1362                 "there",
1363             ],
1364             ['woul(d)', 'i(s)', 'a(re)', 'ha(s)', 'ha(ve)', 'ha(d)', 'wi(ll)'],
1365         ),
1366     ]
1367
1368     # Special cases: can't, shan't and won't.
1369     txt = re.sub(r'\b(can)\s*no(t)\b', r"\1'\2", txt, count=0, flags=re.IGNORECASE)
1370     txt = re.sub(r'\b(sha)ll\s*(n)o(t)\b', r"\1\2'\3", txt, count=0, flags=re.IGNORECASE)
1371     txt = re.sub(
1372         r'\b(w)ill\s*(n)(o)(t)\b',
1373         r"\1\3\2'\4",
1374         txt,
1375         count=0,
1376         flags=re.IGNORECASE,
1377     )
1378
1379     for first_list, second_list in first_second:
1380         for first in first_list:
1381             for second in second_list:
1382                 # Disallow there're/where're.  They're valid English
1383                 # but sound weird.
1384                 if (first in ('there', 'where')) and second == 'a(re)':
1385                     continue
1386
1387                 pattern = fr'\b({first})\s+{second}\b'
1388                 if second == '(n)o(t)':
1389                     replacement = r"\1\2'\3"
1390                 else:
1391                     replacement = r"\1'\2"
1392                 txt = re.sub(pattern, replacement, txt, count=0, flags=re.IGNORECASE)
1393
1394     return txt
1395
1396
1397 def thify(n: int) -> str:
1398     """Return the proper cardinal suffix for a number.
1399
1400     >>> thify(1)
1401     'st'
1402     >>> thify(33)
1403     'rd'
1404     >>> thify(16)
1405     'th'
1406
1407     """
1408     digit = str(n)
1409     assert is_integer_number(digit)
1410     digit = digit[-1:]
1411     if digit == "1":
1412         return "st"
1413     elif digit == "2":
1414         return "nd"
1415     elif digit == "3":
1416         return "rd"
1417     else:
1418         return "th"
1419
1420
1421 def ngrams(txt: str, n: int):
1422     """Return the ngrams from a string.
1423
1424     >>> [x for x in ngrams('This is a test', 2)]
1425     ['This is', 'is a', 'a test']
1426
1427     """
1428     words = txt.split()
1429     for ngram in ngrams_presplit(words, n):
1430         ret = ''
1431         for word in ngram:
1432             ret += f'{word} '
1433         yield ret.strip()
1434
1435
1436 def ngrams_presplit(words: Sequence[str], n: int):
1437     return list_utils.ngrams(words, n)
1438
1439
1440 def bigrams(txt: str):
1441     return ngrams(txt, 2)
1442
1443
1444 def trigrams(txt: str):
1445     return ngrams(txt, 3)
1446
1447
1448 def shuffle_columns_into_list(
1449     input_lines: Sequence[str], column_specs: Iterable[Iterable[int]], delim=''
1450 ) -> Iterable[str]:
1451     """Helper to shuffle / parse columnar data and return the results as a
1452     list.  The column_specs argument is an iterable collection of
1453     numeric sequences that indicate one or more column numbers to
1454     copy.
1455
1456     >>> cols = '-rwxr-xr-x 1 scott wheel 3.1K Jul  9 11:34 acl_test.py'.split()
1457     >>> shuffle_columns_into_list(
1458     ...     cols,
1459     ...     [ [8], [2, 3], [5, 6, 7] ],
1460     ...     delim=' ',
1461     ... )
1462     ['acl_test.py', 'scott wheel', 'Jul 9 11:34']
1463
1464     """
1465     out = []
1466
1467     # Column specs map input lines' columns into outputs.
1468     # [col1, col2...]
1469     for spec in column_specs:
1470         hunk = ''
1471         for n in spec:
1472             hunk = hunk + delim + input_lines[n]
1473         hunk = hunk.strip(delim)
1474         out.append(hunk)
1475     return out
1476
1477
1478 def shuffle_columns_into_dict(
1479     input_lines: Sequence[str],
1480     column_specs: Iterable[Tuple[str, Iterable[int]]],
1481     delim='',
1482 ) -> Dict[str, str]:
1483     """Helper to shuffle / parse columnar data and return the results
1484     as a dict.
1485
1486     >>> cols = '-rwxr-xr-x 1 scott wheel 3.1K Jul  9 11:34 acl_test.py'.split()
1487     >>> shuffle_columns_into_dict(
1488     ...     cols,
1489     ...     [ ('filename', [8]), ('owner', [2, 3]), ('mtime', [5, 6, 7]) ],
1490     ...     delim=' ',
1491     ... )
1492     {'filename': 'acl_test.py', 'owner': 'scott wheel', 'mtime': 'Jul 9 11:34'}
1493
1494     """
1495     out = {}
1496
1497     # Column specs map input lines' columns into outputs.
1498     # "key", [col1, col2...]
1499     for spec in column_specs:
1500         hunk = ''
1501         for n in spec[1]:
1502             hunk = hunk + delim + input_lines[n]
1503         hunk = hunk.strip(delim)
1504         out[spec[0]] = hunk
1505     return out
1506
1507
1508 def interpolate_using_dict(txt: str, values: Dict[str, str]) -> str:
1509     """Interpolate a string with data from a dict.
1510
1511     >>> interpolate_using_dict('This is a {adjective} {noun}.',
1512     ...                        {'adjective': 'good', 'noun': 'example'})
1513     'This is a good example.'
1514
1515     """
1516     return sprintf(txt.format(**values), end='')
1517
1518
1519 def to_ascii(x: str):
1520     """Encode as ascii bytes string.
1521
1522     >>> to_ascii('test')
1523     b'test'
1524
1525     >>> to_ascii(b'1, 2, 3')
1526     b'1, 2, 3'
1527
1528     """
1529     if isinstance(x, str):
1530         return x.encode('ascii')
1531     if isinstance(x, bytes):
1532         return x
1533     raise Exception('to_ascii works with strings and bytes')
1534
1535
1536 def to_base64(txt: str, *, encoding='utf-8', errors='surrogatepass') -> bytes:
1537     """Encode txt and then encode the bytes with a 64-character
1538     alphabet.  This is compatible with uudecode.
1539
1540     >>> to_base64('hello?')
1541     b'aGVsbG8/\\n'
1542
1543     """
1544     return base64.encodebytes(txt.encode(encoding, errors))
1545
1546
1547 def is_base64(txt: str) -> bool:
1548     """Determine whether a string is base64 encoded (with Python's standard
1549     base64 alphabet which is the same as what uuencode uses).
1550
1551     >>> is_base64('test')    # all letters in the b64 alphabet
1552     True
1553
1554     >>> is_base64('another test, how do you like this one?')
1555     False
1556
1557     >>> is_base64(b'aGVsbG8/\\n')    # Ending newline is ok.
1558     True
1559
1560     """
1561     a = string.ascii_uppercase + string.ascii_lowercase + string.digits + '+/'
1562     alphabet = set(a.encode('ascii'))
1563     for char in to_ascii(txt.strip()):
1564         if char not in alphabet:
1565             return False
1566     return True
1567
1568
1569 def from_base64(b64: bytes, encoding='utf-8', errors='surrogatepass') -> str:
1570     """Convert base64 encoded string back to normal strings.
1571
1572     >>> from_base64(b'aGVsbG8/\\n')
1573     'hello?'
1574
1575     """
1576     return base64.decodebytes(b64).decode(encoding, errors)
1577
1578
1579 def chunk(txt: str, chunk_size):
1580     """Chunk up a string.
1581
1582     >>> ' '.join(chunk('010011011100010110101010101010101001111110101000', 8))
1583     '01001101 11000101 10101010 10101010 10011111 10101000'
1584
1585     """
1586     if len(txt) % chunk_size != 0:
1587         msg = f'String to chunk\'s length ({len(txt)} is not an even multiple of chunk_size ({chunk_size})'
1588         logger.warning(msg)
1589         warnings.warn(msg, stacklevel=2)
1590     for x in range(0, len(txt), chunk_size):
1591         yield txt[x : x + chunk_size]
1592
1593
1594 def to_bitstring(txt: str, *, delimiter='', encoding='utf-8', errors='surrogatepass') -> str:
1595     """Encode txt and then chop it into bytes.  Note: only bitstrings
1596     with delimiter='' are interpretable by from_bitstring.
1597
1598     >>> to_bitstring('hello?')
1599     '011010000110010101101100011011000110111100111111'
1600
1601     >>> to_bitstring('test', delimiter=' ')
1602     '01110100 01100101 01110011 01110100'
1603
1604     >>> to_bitstring(b'test')
1605     '01110100011001010111001101110100'
1606
1607     """
1608     etxt = to_ascii(txt)
1609     bits = bin(int.from_bytes(etxt, 'big'))
1610     bits = bits[2:]
1611     return delimiter.join(chunk(bits.zfill(8 * ((len(bits) + 7) // 8)), 8))
1612
1613
1614 def is_bitstring(txt: str) -> bool:
1615     """Is this a bitstring?
1616
1617     >>> is_bitstring('011010000110010101101100011011000110111100111111')
1618     True
1619
1620     >>> is_bitstring('1234')
1621     False
1622
1623     """
1624     return is_binary_integer_number(f'0b{txt}')
1625
1626
1627 def from_bitstring(bits: str, encoding='utf-8', errors='surrogatepass') -> str:
1628     """Convert from bitstring back to bytes then decode into a str.
1629
1630     >>> from_bitstring('011010000110010101101100011011000110111100111111')
1631     'hello?'
1632
1633     """
1634     n = int(bits, 2)
1635     return n.to_bytes((n.bit_length() + 7) // 8, 'big').decode(encoding, errors) or '\0'
1636
1637
1638 def ip_v4_sort_key(txt: str) -> Optional[Tuple[int, ...]]:
1639     """Turn an IPv4 address into a tuple for sorting purposes.
1640
1641     >>> ip_v4_sort_key('10.0.0.18')
1642     (10, 0, 0, 18)
1643
1644     >>> ips = ['10.0.0.10', '100.0.0.1', '1.2.3.4', '10.0.0.9']
1645     >>> sorted(ips, key=lambda x: ip_v4_sort_key(x))
1646     ['1.2.3.4', '10.0.0.9', '10.0.0.10', '100.0.0.1']
1647
1648     """
1649     if not is_ip_v4(txt):
1650         print(f"not IP: {txt}")
1651         return None
1652     return tuple([int(x) for x in txt.split('.')])
1653
1654
1655 def path_ancestors_before_descendants_sort_key(volume: str) -> Tuple[str, ...]:
1656     """Chunk up a file path so that parent/ancestor paths sort before
1657     children/descendant paths.
1658
1659     >>> path_ancestors_before_descendants_sort_key('/usr/local/bin')
1660     ('usr', 'local', 'bin')
1661
1662     >>> paths = ['/usr/local', '/usr/local/bin', '/usr']
1663     >>> sorted(paths, key=lambda x: path_ancestors_before_descendants_sort_key(x))
1664     ['/usr', '/usr/local', '/usr/local/bin']
1665
1666     """
1667     return tuple([x for x in volume.split('/') if len(x) > 0])
1668
1669
1670 def replace_all(in_str: str, replace_set: str, replacement: str) -> str:
1671     """Execute several replace operations in a row.
1672
1673     >>> s = 'this_is a-test!'
1674     >>> replace_all(s, ' _-!', '')
1675     'thisisatest'
1676
1677     """
1678     for char in replace_set:
1679         in_str = in_str.replace(char, replacement)
1680     return in_str
1681
1682
1683 if __name__ == '__main__':
1684     import doctest
1685
1686     doctest.testmod()