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