Easier and more self documenting patterns for loading/saving Persistent
[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     Args:
171         in_str: the string to test
172
173     Returns:
174         True if the input string is either None or an empty string,
175         False otherwise.
176
177     >>> is_none_or_empty("")
178     True
179     >>> is_none_or_empty(None)
180     True
181     >>> is_none_or_empty("   \t   ")
182     True
183     >>> is_none_or_empty('Test')
184     False
185     """
186     return in_str is None or len(in_str.strip()) == 0
187
188
189 def is_string(obj: Any) -> bool:
190     """
191     Args:
192         in_str: the object to test
193
194     Returns:
195         True if the object is a string and False otherwise.
196
197     >>> is_string('test')
198     True
199     >>> is_string(123)
200     False
201     >>> is_string(100.3)
202     False
203     >>> is_string([1, 2, 3])
204     False
205     """
206     return isinstance(obj, str)
207
208
209 def is_empty_string(in_str: Any) -> bool:
210     """
211     Args:
212         in_str: the string to test
213
214     Returns:
215         True if the string is empty and False otherwise.
216     """
217     return is_empty(in_str)
218
219
220 def is_empty(in_str: Any) -> bool:
221     """
222     Args:
223         in_str: the string to test
224
225     Returns:
226         True if the string is empty and false otherwise.
227
228     >>> is_empty('')
229     True
230     >>> is_empty('    \t\t    ')
231     True
232     >>> is_empty('test')
233     False
234     >>> is_empty(100.88)
235     False
236     >>> is_empty([1, 2, 3])
237     False
238     """
239     return is_string(in_str) and in_str.strip() == ""
240
241
242 def is_full_string(in_str: Any) -> bool:
243     """
244     Args:
245         in_str: the object to test
246
247     Returns:
248         True if the object is a string and is not empty ('') and
249         is not only composed of whitespace.
250
251     >>> is_full_string('test!')
252     True
253     >>> is_full_string('')
254     False
255     >>> is_full_string('      ')
256     False
257     >>> is_full_string(100.999)
258     False
259     >>> is_full_string({"a": 1, "b": 2})
260     False
261     """
262     return is_string(in_str) and in_str.strip() != ""
263
264
265 def is_number(in_str: str) -> bool:
266     """
267     Args:
268         in_str: the string to test
269
270     Returns:
271         True if the string contains a valid numberic value and
272         False otherwise.
273
274     >>> is_number(100.5)
275     Traceback (most recent call last):
276     ...
277     ValueError: 100.5
278     >>> is_number("100.5")
279     True
280     >>> is_number("test")
281     False
282     >>> is_number("99")
283     True
284     >>> is_number([1, 2, 3])
285     Traceback (most recent call last):
286     ...
287     ValueError: [1, 2, 3]
288     """
289     if not is_string(in_str):
290         raise ValueError(in_str)
291     return NUMBER_RE.match(in_str) is not None
292
293
294 def is_integer_number(in_str: str) -> bool:
295     """
296     Args:
297         in_str: the string to test
298
299     Returns:
300         True if the string contains a valid (signed or unsigned,
301         decimal, hex, or octal, regular or scientific) integral
302         expression and False otherwise.
303
304     >>> is_integer_number('42')
305     True
306     >>> is_integer_number('42.0')
307     False
308     """
309     return (
310         (is_number(in_str) and "." not in in_str)
311         or is_hexidecimal_integer_number(in_str)
312         or is_octal_integer_number(in_str)
313         or is_binary_integer_number(in_str)
314     )
315
316
317 def is_hexidecimal_integer_number(in_str: str) -> bool:
318     """
319     Args:
320         in_str: the string to test
321
322     Returns:
323         True if the string is a hex integer number and False otherwise.
324
325     >>> is_hexidecimal_integer_number('0x12345')
326     True
327     >>> is_hexidecimal_integer_number('0x1A3E')
328     True
329     >>> is_hexidecimal_integer_number('1234')  # Needs 0x
330     False
331     >>> is_hexidecimal_integer_number('-0xff')
332     True
333     >>> is_hexidecimal_integer_number('test')
334     False
335     >>> is_hexidecimal_integer_number(12345)  # Not a string
336     Traceback (most recent call last):
337     ...
338     ValueError: 12345
339     >>> is_hexidecimal_integer_number(101.4)
340     Traceback (most recent call last):
341     ...
342     ValueError: 101.4
343     >>> is_hexidecimal_integer_number(0x1A3E)
344     Traceback (most recent call last):
345     ...
346     ValueError: 6718
347     """
348     if not is_string(in_str):
349         raise ValueError(in_str)
350     return HEX_NUMBER_RE.match(in_str) is not None
351
352
353 def is_octal_integer_number(in_str: str) -> bool:
354     """
355     Args:
356         in_str: the string to test
357
358     Returns:
359         True if the string is a valid octal integral number and False otherwise.
360
361     >>> is_octal_integer_number('0o777')
362     True
363     >>> is_octal_integer_number('-0O115')
364     True
365     >>> is_octal_integer_number('0xFF')  # Not octal, needs 0o
366     False
367     >>> is_octal_integer_number('7777')  # Needs 0o
368     False
369     >>> is_octal_integer_number('test')
370     False
371     """
372     if not is_string(in_str):
373         raise ValueError(in_str)
374     return OCT_NUMBER_RE.match(in_str) is not None
375
376
377 def is_binary_integer_number(in_str: str) -> bool:
378     """
379     Args:
380         in_str: the string to test
381
382     Returns:
383         True if the string contains a binary integral number and False otherwise.
384
385     >>> is_binary_integer_number('0b10111')
386     True
387     >>> is_binary_integer_number('-0b111')
388     True
389     >>> is_binary_integer_number('0B10101')
390     True
391     >>> is_binary_integer_number('0b10102')
392     False
393     >>> is_binary_integer_number('0xFFF')
394     False
395     >>> is_binary_integer_number('test')
396     False
397     """
398     if not is_string(in_str):
399         raise ValueError(in_str)
400     return BIN_NUMBER_RE.match(in_str) is not None
401
402
403 def to_int(in_str: str) -> int:
404     """
405     Args:
406         in_str: the string to convert
407
408     Returns:
409         The integral value of the string or raises on error.
410
411     >>> to_int('1234')
412     1234
413     >>> to_int('test')
414     Traceback (most recent call last):
415     ...
416     ValueError: invalid literal for int() with base 10: 'test'
417     """
418     if not is_string(in_str):
419         raise ValueError(in_str)
420     if is_binary_integer_number(in_str):
421         return int(in_str, 2)
422     if is_octal_integer_number(in_str):
423         return int(in_str, 8)
424     if is_hexidecimal_integer_number(in_str):
425         return int(in_str, 16)
426     return int(in_str)
427
428
429 def is_decimal_number(in_str: str) -> bool:
430     """
431     Args:
432         in_str: the string to check
433
434     Returns:
435         True if the given string represents a decimal or False
436         otherwise.  A decimal may be signed or unsigned or use
437         a "scientific notation".
438
439     .. note::
440         We do not consider integers without a decimal point
441         to be decimals; they return False (see example).
442
443     >>> is_decimal_number('42.0')
444     True
445     >>> is_decimal_number('42')
446     False
447     """
448     return is_number(in_str) and "." in in_str
449
450
451 def strip_escape_sequences(in_str: str) -> str:
452     """
453     Args:
454         in_str: the string to strip of escape sequences.
455
456     Returns:
457         in_str with escape sequences removed.
458
459     .. note::
460         What is considered to be an "escape sequence" is defined
461         by a regular expression.  While this gets common ones,
462         there may exist valid sequences that it doesn't match.
463
464     >>> strip_escape_sequences('\e[12;11;22mthis is a test!')
465     'this is a test!'
466     """
467     in_str = ESCAPE_SEQUENCE_RE.sub("", in_str)
468     return in_str
469
470
471 def add_thousands_separator(in_str: str, *, separator_char=',', places=3) -> str:
472     """
473     Args:
474         in_str: string or number to which to add thousands separator(s)
475         separator_char: the separator character to add (defaults to comma)
476         places: add a separator every N places (defaults to three)
477
478     Returns:
479         A numeric string with thousands separators added appropriately.
480
481     >>> add_thousands_separator('12345678')
482     '12,345,678'
483     >>> add_thousands_separator(12345678)
484     '12,345,678'
485     >>> add_thousands_separator(12345678.99)
486     '12,345,678.99'
487     >>> add_thousands_separator('test')
488     Traceback (most recent call last):
489     ...
490     ValueError: test
491
492     """
493     if isinstance(in_str, numbers.Number):
494         in_str = f'{in_str}'
495     if is_number(in_str):
496         return _add_thousands_separator(in_str, separator_char=separator_char, places=places)
497     raise ValueError(in_str)
498
499
500 def _add_thousands_separator(in_str: str, *, separator_char=',', places=3) -> str:
501     decimal_part = ""
502     if '.' in in_str:
503         (in_str, decimal_part) = in_str.split('.')
504     tmp = [iter(in_str[::-1])] * places
505     ret = separator_char.join("".join(x) for x in zip_longest(*tmp, fillvalue=""))[::-1]
506     if len(decimal_part) > 0:
507         ret += '.'
508         ret += decimal_part
509     return ret
510
511
512 def is_url(in_str: Any, allowed_schemes: Optional[List[str]] = None) -> bool:
513     """
514     Args:
515         in_str: the string to test
516         allowed_schemes: an optional list of allowed schemes (e.g.
517             ['http', 'https', 'ftp'].  If passed, only URLs that
518             begin with the one of the schemes passed will be considered
519             to be valid.  Otherwise, any scheme:// will be considered
520             valid.
521
522     Returns:
523         True if in_str contains a valid URL and False otherwise.
524
525     >>> is_url('http://www.mysite.com')
526     True
527     >>> is_url('https://mysite.com')
528     True
529     >>> is_url('.mysite.com')
530     False
531     >>> is_url('scheme://username:[email protected]:8042/folder/subfolder/file.extension?param=value&param2=value2#hash')
532     True
533     """
534     if not is_full_string(in_str):
535         return False
536
537     valid = URL_RE.match(in_str) is not None
538
539     if allowed_schemes:
540         return valid and any([in_str.startswith(s) for s in allowed_schemes])
541     return valid
542
543
544 def is_email(in_str: Any) -> bool:
545     """
546     Args:
547         in_str: the email address to check
548
549     Returns: True if the in_str contains a valid email (as defined by
550         https://tools.ietf.org/html/rfc3696#section-3) or False
551         otherwise.
552
553     >>> is_email('[email protected]')
554     True
555     >>> is_email('@gmail.com')
556     False
557     """
558     if not is_full_string(in_str) or len(in_str) > 320 or in_str.startswith("."):
559         return False
560
561     try:
562         # we expect 2 tokens, one before "@" and one after, otherwise
563         # we have an exception and the email is not valid.
564         head, tail = in_str.split("@")
565
566         # head's size must be <= 64, tail <= 255, head must not start
567         # with a dot or contain multiple consecutive dots.
568         if len(head) > 64 or len(tail) > 255 or head.endswith(".") or (".." in head):
569             return False
570
571         # removes escaped spaces, so that later on the test regex will
572         # accept the string.
573         head = head.replace("\\ ", "")
574         if head.startswith('"') and head.endswith('"'):
575             head = head.replace(" ", "")[1:-1]
576         return EMAIL_RE.match(head + "@" + tail) is not None
577
578     except ValueError:
579         # borderline case in which we have multiple "@" signs but the
580         # head part is correctly escaped.
581         if ESCAPED_AT_SIGN.search(in_str) is not None:
582             # replace "@" with "a" in the head
583             return is_email(ESCAPED_AT_SIGN.sub("a", in_str))
584         return False
585
586
587 def suffix_string_to_number(in_str: str) -> Optional[int]:
588     """Takes a string like "33Gb" and converts it into a number (of bytes)
589     like 34603008.
590
591     Args:
592         in_str: the string with a suffix to be interpreted and removed.
593
594     Returns:
595         An integer number of bytes or None to indicate an error.
596
597     >>> suffix_string_to_number('1Mb')
598     1048576
599     >>> suffix_string_to_number('13.1Gb')
600     14066017894
601     """
602
603     def suffix_capitalize(s: str) -> str:
604         if len(s) == 1:
605             return s.upper()
606         elif len(s) == 2:
607             return f"{s[0].upper()}{s[1].lower()}"
608         return suffix_capitalize(s[0:1])
609
610     if is_string(in_str):
611         if is_integer_number(in_str):
612             return to_int(in_str)
613         suffixes = [in_str[-2:], in_str[-1:]]
614         rest = [in_str[:-2], in_str[:-1]]
615         for x in range(len(suffixes)):
616             s = suffixes[x]
617             s = suffix_capitalize(s)
618             multiplier = NUM_SUFFIXES.get(s, None)
619             if multiplier is not None:
620                 r = rest[x]
621                 if is_integer_number(r):
622                     return to_int(r) * multiplier
623                 if is_decimal_number(r):
624                     return int(float(r) * multiplier)
625     return None
626
627
628 def number_to_suffix_string(num: int) -> Optional[str]:
629     """Take a number (of bytes) and returns a string like "43.8Gb".
630
631     Args:
632         num: an integer number of bytes
633
634     Returns:
635         A string with a suffix representing num bytes concisely or
636         None to indicate an error.
637
638     >>> number_to_suffix_string(14066017894)
639     '13.1Gb'
640     >>> number_to_suffix_string(1024 * 1024)
641     '1.0Mb'
642     """
643     d = 0.0
644     suffix = None
645     for (sfx, size) in NUM_SUFFIXES.items():
646         if num >= size:
647             d = num / size
648             suffix = sfx
649             break
650     if suffix is not None:
651         return f"{d:.1f}{suffix}"
652     else:
653         return f'{num:d}'
654
655
656 def is_credit_card(in_str: Any, card_type: str = None) -> bool:
657     """
658     Args:
659         in_str: a string to check
660         card_type: if provided, contains the card type to validate
661             with.  Otherwise, all known credit card number types will
662             be accepted.
663
664             Supported card types are the following:
665
666             * VISA
667             * MASTERCARD
668             * AMERICAN_EXPRESS
669             * DINERS_CLUB
670             * DISCOVER
671             * JCB
672
673     Returns:
674         True if in_str is a valid credit card number.
675     """
676     if not is_full_string(in_str):
677         return False
678
679     if card_type is not None:
680         if card_type not in CREDIT_CARDS:
681             raise KeyError(
682                 f'Invalid card type "{card_type}". Valid types are: {CREDIT_CARDS.keys()}'
683             )
684         return CREDIT_CARDS[card_type].match(in_str) is not None
685     for c in CREDIT_CARDS:
686         if CREDIT_CARDS[c].match(in_str) is not None:
687             return True
688     return False
689
690
691 def is_camel_case(in_str: Any) -> bool:
692     """
693     Args:
694         in_str: the string to test
695
696     Returns:
697         True if the string is formatted as camel case and False otherwise.
698         A string is considered camel case when:
699
700         * it's composed only by letters ([a-zA-Z]) and optionally numbers ([0-9])
701         * it contains both lowercase and uppercase letters
702         * it does not start with a number
703     """
704     return is_full_string(in_str) and CAMEL_CASE_TEST_RE.match(in_str) is not None
705
706
707 def is_snake_case(in_str: Any, *, separator: str = "_") -> bool:
708     """
709     Args:
710         in_str: the string to test
711
712     Returns: True if the string is snake case and False otherwise.  A
713         string is considered snake case when:
714
715         * it's composed only by lowercase/uppercase letters and digits
716         * it contains at least one underscore (or provided separator)
717         * it does not start with a number
718
719     >>> is_snake_case('this_is_a_test')
720     True
721     >>> is_snake_case('___This_Is_A_Test_1_2_3___')
722     True
723     >>> is_snake_case('this-is-a-test')
724     False
725     >>> is_snake_case('this-is-a-test', separator='-')
726     True
727     """
728     if is_full_string(in_str):
729         re_map = {"_": SNAKE_CASE_TEST_RE, "-": SNAKE_CASE_TEST_DASH_RE}
730         re_template = r"([a-z]+\d*{sign}[a-z\d{sign}]*|{sign}+[a-z\d]+[a-z\d{sign}]*)"
731         r = re_map.get(
732             separator,
733             re.compile(re_template.format(sign=re.escape(separator)), re.IGNORECASE),
734         )
735         return r.match(in_str) is not None
736     return False
737
738
739 def is_json(in_str: Any) -> bool:
740     """
741     Args:
742         in_str: the string to test
743
744     Returns:
745         True if the in_str contains valid JSON and False otherwise.
746
747     >>> is_json('{"name": "Peter"}')
748     True
749     >>> is_json('[1, 2, 3]')
750     True
751     >>> is_json('{nope}')
752     False
753     """
754     if is_full_string(in_str) and JSON_WRAPPER_RE.match(in_str) is not None:
755         try:
756             return isinstance(json.loads(in_str), (dict, list))
757         except (TypeError, ValueError, OverflowError):
758             pass
759     return False
760
761
762 def is_uuid(in_str: Any, allow_hex: bool = False) -> bool:
763     """
764     Args:
765         in_str: the string to test
766
767     Returns:
768         True if the in_str contains a valid UUID and False otherwise.
769
770     >>> is_uuid('6f8aa2f9-686c-4ac3-8766-5712354a04cf')
771     True
772     >>> is_uuid('6f8aa2f9686c4ac387665712354a04cf')
773     False
774     >>> is_uuid('6f8aa2f9686c4ac387665712354a04cf', allow_hex=True)
775     True
776     """
777     # string casting is used to allow UUID itself as input data type
778     s = str(in_str)
779     if allow_hex:
780         return UUID_HEX_OK_RE.match(s) is not None
781     return UUID_RE.match(s) is not None
782
783
784 def is_ip_v4(in_str: Any) -> bool:
785     """
786     Args:
787         in_str: the string to test
788
789     Returns:
790         True if in_str contains a valid IPv4 address and False otherwise.
791
792     >>> is_ip_v4('255.200.100.75')
793     True
794     >>> is_ip_v4('nope')
795     False
796     >>> is_ip_v4('255.200.100.999')  # 999 out of range
797     False
798     """
799     if not is_full_string(in_str) or SHALLOW_IP_V4_RE.match(in_str) is None:
800         return False
801
802     # checks that each entry in the ip is in the valid range (0 to 255)
803     for token in in_str.split("."):
804         if not 0 <= int(token) <= 255:
805             return False
806     return True
807
808
809 def extract_ip_v4(in_str: Any) -> Optional[str]:
810     """
811     Args:
812         in_str: the string to extract an IPv4 address from.
813
814     Returns:
815         The first extracted IPv4 address from in_str or None if
816         none were found or an error occurred.
817
818     >>> extract_ip_v4('   The secret IP address: 127.0.0.1 (use it wisely)   ')
819     '127.0.0.1'
820     >>> extract_ip_v4('Your mom dresses you funny.')
821     """
822     if not is_full_string(in_str):
823         return None
824     m = ANYWHERE_IP_V4_RE.search(in_str)
825     if m is not None:
826         return m.group(0)
827     return None
828
829
830 def is_ip_v6(in_str: Any) -> bool:
831     """
832     Args:
833         in_str: the string to test.
834
835     Returns:
836         True if in_str contains a valid IPv6 address and False otherwise.
837
838     >>> is_ip_v6('2001:db8:85a3:0000:0000:8a2e:370:7334')
839     True
840     >>> is_ip_v6('2001:db8:85a3:0000:0000:8a2e:370:?')    # invalid "?"
841     False
842     """
843     return is_full_string(in_str) and IP_V6_RE.match(in_str) is not None
844
845
846 def extract_ip_v6(in_str: Any) -> Optional[str]:
847     """
848     Args:
849         in_str: the string from which to extract an IPv6 address.
850
851     Returns:
852         The first IPv6 address found in in_str or None if no address
853         was found or an error occurred.
854
855     >>> extract_ip_v6('IP: 2001:db8:85a3:0000:0000:8a2e:370:7334')
856     '2001:db8:85a3:0000:0000:8a2e:370:7334'
857     >>> extract_ip_v6("(and she's ugly too, btw)")
858     """
859     if not is_full_string(in_str):
860         return None
861     m = ANYWHERE_IP_V6_RE.search(in_str)
862     if m is not None:
863         return m.group(0)
864     return None
865
866
867 def is_ip(in_str: Any) -> bool:
868     """
869     Args:
870         in_str: the string to test.
871
872     Returns:
873         True if in_str contains a valid IP address (either IPv4 or
874         IPv6).
875
876     >>> is_ip('255.200.100.75')
877     True
878     >>> is_ip('2001:db8:85a3:0000:0000:8a2e:370:7334')
879     True
880     >>> is_ip('1.2.3')
881     False
882     >>> is_ip('1.2.3.999')
883     False
884     """
885     return is_ip_v6(in_str) or is_ip_v4(in_str)
886
887
888 def extract_ip(in_str: Any) -> Optional[str]:
889     """
890     Args:
891         in_str: the string from which to extract in IP address.
892
893     Returns:
894         The first IP address (IPv4 or IPv6) found in in_str or
895         None to indicate none found or an error condition.
896
897     >>> extract_ip('Attacker: 255.200.100.75')
898     '255.200.100.75'
899     >>> extract_ip('Remote host: 2001:db8:85a3:0000:0000:8a2e:370:7334')
900     '2001:db8:85a3:0000:0000:8a2e:370:7334'
901     >>> extract_ip('1.2.3')
902     """
903     ip = extract_ip_v4(in_str)
904     if ip is None:
905         ip = extract_ip_v6(in_str)
906     return ip
907
908
909 def is_mac_address(in_str: Any) -> bool:
910     """
911     Args:
912         in_str: the string to test
913
914     Returns:
915         True if in_str is a valid MAC address False otherwise.
916
917     >>> is_mac_address("34:29:8F:12:0D:2F")
918     True
919     >>> is_mac_address('34:29:8f:12:0d:2f')
920     True
921     >>> is_mac_address('34-29-8F-12-0D-2F')
922     True
923     >>> is_mac_address("test")
924     False
925     """
926     return is_full_string(in_str) and MAC_ADDRESS_RE.match(in_str) is not None
927
928
929 def extract_mac_address(in_str: Any, *, separator: str = ":") -> Optional[str]:
930     """
931     Args:
932         in_str: the string from which to extract a MAC address.
933
934     Returns:
935         The first MAC address found in in_str or None to indicate no
936         match or an error.
937
938     >>> extract_mac_address(' MAC Address: 34:29:8F:12:0D:2F')
939     '34:29:8F:12:0D:2F'
940
941     >>> extract_mac_address('? (10.0.0.30) at d8:5d:e2:34:54:86 on em0 expires in 1176 seconds [ethernet]')
942     'd8:5d:e2:34:54:86'
943     """
944     if not is_full_string(in_str):
945         return None
946     in_str.strip()
947     m = ANYWHERE_MAC_ADDRESS_RE.search(in_str)
948     if m is not None:
949         mac = m.group(0)
950         mac.replace(":", separator)
951         mac.replace("-", separator)
952         return mac
953     return None
954
955
956 def is_slug(in_str: Any, separator: str = "-") -> bool:
957     """
958     Args:
959         in_str: string to test
960
961     Returns:
962         True if in_str is a slug string and False otherwise.
963
964     >>> is_slug('my-blog-post-title')
965     True
966     >>> is_slug('My blog post title')
967     False
968     """
969     if not is_full_string(in_str):
970         return False
971     rex = r"^([a-z\d]+" + re.escape(separator) + r"*?)*[a-z\d]$"
972     return re.match(rex, in_str) is not None
973
974
975 def contains_html(in_str: str) -> bool:
976     """
977     Args:
978         in_str: the string to check for tags in
979
980     Returns:
981         True if the given string contains HTML/XML tags and False
982         otherwise.
983
984     .. warning::
985         By design, this function matches ANY type of tag, so don't expect
986         to use it as an HTML validator.  It's a quick sanity check at
987         best.  See something like BeautifulSoup for a more full-featuered
988         HTML parser.
989
990     >>> contains_html('my string is <strong>bold</strong>')
991     True
992     >>> contains_html('my string is not bold')
993     False
994
995     """
996     if not is_string(in_str):
997         raise ValueError(in_str)
998     return HTML_RE.search(in_str) is not None
999
1000
1001 def words_count(in_str: str) -> int:
1002     """
1003     Args:
1004         in_str: the string to count words in
1005
1006     Returns:
1007         The number of words contained in the given string.
1008
1009     .. note::
1010
1011         This method is "smart" in that it does consider only sequences
1012         of one or more letter and/or numbers to be "words".  Thus a
1013         string like this: "! @ # % ... []" will return zero.  Moreover
1014         it is aware of punctuation, so the count for a string like
1015         "one,two,three.stop" will be 4 not 1 (even if there are no spaces
1016         in the string).
1017
1018     >>> words_count('hello world')
1019     2
1020     >>> words_count('one,two,three.stop')
1021     4
1022     """
1023     if not is_string(in_str):
1024         raise ValueError(in_str)
1025     return len(WORDS_COUNT_RE.findall(in_str))
1026
1027
1028 def word_count(in_str: str) -> int:
1029     """
1030     Args:
1031         in_str: the string to count words in
1032
1033     Returns:
1034         The number of words contained in the given string.
1035
1036     .. note::
1037
1038         This method is "smart" in that it does consider only sequences
1039         of one or more letter and/or numbers to be "words".  Thus a
1040         string like this: "! @ # % ... []" will return zero.  Moreover
1041         it is aware of punctuation, so the count for a string like
1042         "one,two,three.stop" will be 4 not 1 (even if there are no spaces
1043         in the string).
1044
1045     >>> word_count('hello world')
1046     2
1047     >>> word_count('one,two,three.stop')
1048     4
1049     """
1050     return words_count(in_str)
1051
1052
1053 def generate_uuid(omit_dashes: bool = False) -> str:
1054     """
1055     Args:
1056         omit_dashes: should we omit the dashes in the generated UUID?
1057
1058     Returns:
1059         A generated UUID string (using `uuid.uuid4()`) with or without
1060         dashes per the omit_dashes arg.
1061
1062     generate_uuid() # possible output: '97e3a716-6b33-4ab9-9bb1-8128cb24d76b'
1063     generate_uuid(omit_dashes=True) # possible output: '97e3a7166b334ab99bb18128cb24d76b'
1064     """
1065     uid = uuid4()
1066     if omit_dashes:
1067         return uid.hex
1068     return str(uid)
1069
1070
1071 def generate_random_alphanumeric_string(size: int) -> str:
1072     """
1073     Args:
1074         size: number of characters to generate
1075
1076     Returns:
1077         A string of the specified size containing random characters
1078         (uppercase/lowercase ascii letters and digits).
1079
1080     >>> random.seed(22)
1081     >>> generate_random_alphanumeric_string(9)
1082     '96ipbNClS'
1083     """
1084     if size < 1:
1085         raise ValueError("size must be >= 1")
1086     chars = string.ascii_letters + string.digits
1087     buffer = [random.choice(chars) for _ in range(size)]
1088     return from_char_list(buffer)
1089
1090
1091 def reverse(in_str: str) -> str:
1092     """
1093     Args:
1094         in_str: the string to reverse
1095
1096     Returns:
1097         The reversed (chracter by character) string.
1098
1099     >>> reverse('test')
1100     'tset'
1101     """
1102     if not is_string(in_str):
1103         raise ValueError(in_str)
1104     return in_str[::-1]
1105
1106
1107 def camel_case_to_snake_case(in_str, *, separator="_"):
1108     """
1109     Args:
1110         in_str: the camel case string to convert
1111
1112     Returns:
1113         A snake case string equivalent to the camel case input or the
1114         original string if it is not a valid camel case string or some
1115         other error occurs.
1116
1117     >>> camel_case_to_snake_case('MacAddressExtractorFactory')
1118     'mac_address_extractor_factory'
1119     >>> camel_case_to_snake_case('Luke Skywalker')
1120     'Luke Skywalker'
1121     """
1122     if not is_string(in_str):
1123         raise ValueError(in_str)
1124     if not is_camel_case(in_str):
1125         return in_str
1126     return CAMEL_CASE_REPLACE_RE.sub(lambda m: m.group(1) + separator, in_str).lower()
1127
1128
1129 def snake_case_to_camel_case(
1130     in_str: str, *, upper_case_first: bool = True, separator: str = "_"
1131 ) -> str:
1132     """
1133     Args:
1134         in_str: the snake case string to convert
1135
1136     Returns:
1137         A camel case string that is equivalent to the snake case string
1138         provided or the original string back again if it is not valid
1139         snake case or another error occurs.
1140
1141     >>> snake_case_to_camel_case('this_is_a_test')
1142     'ThisIsATest'
1143     >>> snake_case_to_camel_case('Han Solo')
1144     'Han Solo'
1145     """
1146     if not is_string(in_str):
1147         raise ValueError(in_str)
1148     if not is_snake_case(in_str, separator=separator):
1149         return in_str
1150     tokens = [s.title() for s in in_str.split(separator) if is_full_string(s)]
1151     if not upper_case_first:
1152         tokens[0] = tokens[0].lower()
1153     return from_char_list(tokens)
1154
1155
1156 def to_char_list(in_str: str) -> List[str]:
1157     """
1158     Args:
1159         in_str: the string to split into a char list
1160
1161     Returns:
1162         A list of strings of length one each.
1163
1164     >>> to_char_list('test')
1165     ['t', 'e', 's', 't']
1166     """
1167     if not is_string(in_str):
1168         return []
1169     return list(in_str)
1170
1171
1172 def from_char_list(in_list: List[str]) -> str:
1173     """
1174     Args:
1175         in_list: A list of characters to convert into a string.
1176
1177     Returns:
1178         The string resulting from gluing the characters in in_list
1179         together.
1180
1181     >>> from_char_list(['t', 'e', 's', 't'])
1182     'test'
1183     """
1184     return "".join(in_list)
1185
1186
1187 def shuffle(in_str: str) -> Optional[str]:
1188     """
1189     Args:
1190         in_str: a string to shuffle randomly by character
1191
1192     Returns:
1193         A new string containing same chars of the given one but in
1194         a randomized order.  Note that in rare cases this could result
1195         in the same original string as no check is done.  Returns
1196         None to indicate error conditions.
1197
1198     >>> random.seed(22)
1199     >>> shuffle('awesome')
1200     'meosaew'
1201     """
1202     if not is_string(in_str):
1203         return None
1204     chars = to_char_list(in_str)
1205     random.shuffle(chars)
1206     return from_char_list(chars)
1207
1208
1209 def scramble(in_str: str) -> Optional[str]:
1210     """
1211     Args:
1212         in_str: a string to shuffle randomly by character
1213
1214     Returns:
1215         A new string containing same chars of the given one but in
1216         a randomized order.  Note that in rare cases this could result
1217         in the same original string as no check is done.  Returns
1218         None to indicate error conditions.
1219
1220     >>> random.seed(22)
1221     >>> scramble('awesome')
1222     'meosaew'
1223     """
1224     return shuffle(in_str)
1225
1226
1227 def strip_html(in_str: str, keep_tag_content: bool = False) -> str:
1228     """
1229     Args:
1230         in_str: the string to strip tags from
1231         keep_tag_content: should we keep the inner contents of tags?
1232
1233     Returns:
1234         A string with all HTML tags removed (optionally with tag contents
1235         preserved).
1236
1237     .. note::
1238         This method uses simple regular expressions to strip tags and is
1239         not a full fledged HTML parser by any means.  Consider using
1240         something like BeautifulSoup if your needs are more than this
1241         simple code can fulfill.
1242
1243     >>> strip_html('test: <a href="foo/bar">click here</a>')
1244     'test: '
1245     >>> strip_html('test: <a href="foo/bar">click here</a>', keep_tag_content=True)
1246     'test: click here'
1247     """
1248     if not is_string(in_str):
1249         raise ValueError(in_str)
1250     r = HTML_TAG_ONLY_RE if keep_tag_content else HTML_RE
1251     return r.sub("", in_str)
1252
1253
1254 def asciify(in_str: str) -> str:
1255     """
1256     Args:
1257         in_str: the string to asciify.
1258
1259     Returns:
1260         An output string roughly equivalent to the original string
1261         where all content to are ascii-only.  This is accomplished
1262         by translating all non-ascii chars into their closest possible
1263         ASCII representation (eg: ó -> o, Ë -> E, ç -> c...).
1264
1265     .. warning::
1266         Some chars may be lost if impossible to translate.
1267
1268     >>> asciify('èéùúòóäåëýñÅÀÁÇÌÍÑÓË')
1269     'eeuuooaaeynAAACIINOE'
1270     """
1271     if not is_string(in_str):
1272         raise ValueError(in_str)
1273
1274     # "NFKD" is the algorithm which is able to successfully translate
1275     # the most of non-ascii chars.
1276     normalized = unicodedata.normalize("NFKD", in_str)
1277
1278     # encode string forcing ascii and ignore any errors
1279     # (unrepresentable chars will be stripped out)
1280     ascii_bytes = normalized.encode("ascii", "ignore")
1281
1282     # turns encoded bytes into an utf-8 string
1283     return ascii_bytes.decode("utf-8")
1284
1285
1286 def slugify(in_str: str, *, separator: str = "-") -> str:
1287     """
1288     Args:
1289         in_str: the string to slugify
1290         separator: the character to use during sligification (default
1291             is a dash)
1292
1293     Returns:
1294         The converted string.  The returned string has the following properties:
1295
1296         * it has no spaces
1297         * all letters are in lower case
1298         * all punctuation signs and non alphanumeric chars are removed
1299         * words are divided using provided separator
1300         * all chars are encoded as ascii (by using :meth:`asciify`)
1301         * is safe for URL
1302
1303     >>> slugify('Top 10 Reasons To Love Dogs!!!')
1304     'top-10-reasons-to-love-dogs'
1305     >>> slugify('Mönstér Mägnët')
1306     'monster-magnet'
1307     """
1308     if not is_string(in_str):
1309         raise ValueError(in_str)
1310
1311     # replace any character that is NOT letter or number with spaces
1312     out = NO_LETTERS_OR_NUMBERS_RE.sub(" ", in_str.lower()).strip()
1313
1314     # replace spaces with join sign
1315     out = SPACES_RE.sub(separator, out)
1316
1317     # normalize joins (remove duplicates)
1318     out = re.sub(re.escape(separator) + r"+", separator, out)
1319     return asciify(out)
1320
1321
1322 def to_bool(in_str: str) -> bool:
1323     """
1324     Args:
1325         in_str: the string to convert to boolean
1326
1327     Returns:
1328         A boolean equivalent of the original string based on its contents.
1329         All conversion is case insensitive.  A positive boolean (True) is
1330         returned if the string value is any of the following:
1331
1332         * "true"
1333         * "t"
1334         * "1"
1335         * "yes"
1336         * "y"
1337         * "on"
1338
1339         Otherwise False is returned.
1340
1341     >>> to_bool('True')
1342     True
1343
1344     >>> to_bool('1')
1345     True
1346
1347     >>> to_bool('yes')
1348     True
1349
1350     >>> to_bool('no')
1351     False
1352
1353     >>> to_bool('huh?')
1354     False
1355
1356     >>> to_bool('on')
1357     True
1358     """
1359     if not is_string(in_str):
1360         raise ValueError(in_str)
1361     return in_str.lower() in ("true", "1", "yes", "y", "t", "on")
1362
1363
1364 def to_date(in_str: str) -> Optional[datetime.date]:
1365     """
1366     Args:
1367         in_str: the string to convert into a date
1368
1369     Returns:
1370         The datetime.date the string contained or None to indicate
1371         an error.  This parser is relatively clever; see
1372         :class:`python_modules.dateparse.dateparse_utils` docs for
1373         details.
1374
1375     >>> to_date('9/11/2001')
1376     datetime.date(2001, 9, 11)
1377     >>> to_date('xyzzy')
1378     """
1379     import dateparse.dateparse_utils as du
1380
1381     try:
1382         d = du.DateParser()  # type: ignore
1383         d.parse(in_str)
1384         return d.get_date()
1385     except du.ParseException:  # type: ignore
1386         msg = f'Unable to parse date {in_str}.'
1387         logger.warning(msg)
1388     return None
1389
1390
1391 def extract_date(in_str: Any) -> Optional[datetime.datetime]:
1392     """Finds and extracts a date from the string, if possible.
1393
1394     Args:
1395         in_str: the string to extract a date from
1396
1397     Returns:
1398         a datetime if date was found, otherwise None
1399
1400     >>> extract_date("filename.txt    dec 13, 2022")
1401     datetime.datetime(2022, 12, 13, 0, 0)
1402
1403     >>> extract_date("Dear Santa, please get me a pony.")
1404
1405     """
1406     import itertools
1407
1408     import dateparse.dateparse_utils as du
1409
1410     d = du.DateParser()  # type: ignore
1411     chunks = in_str.split()
1412     for ngram in itertools.chain(
1413         list_utils.ngrams(chunks, 5),
1414         list_utils.ngrams(chunks, 4),
1415         list_utils.ngrams(chunks, 3),
1416         list_utils.ngrams(chunks, 2),
1417     ):
1418         try:
1419             expr = " ".join(ngram)
1420             logger.debug(f"Trying {expr}")
1421             if d.parse(expr):
1422                 return d.get_datetime()
1423         except du.ParseException:  # type: ignore
1424             pass
1425     return None
1426
1427
1428 def is_valid_date(in_str: str) -> bool:
1429     """
1430     Args:
1431         in_str: the string to check
1432
1433     Returns:
1434         True if the string represents a valid date that we can recognize
1435         and False otherwise.  This parser is relatively clever; see
1436         :class:`python_modules.dateparse.dateparse_utils` docs for
1437         details.
1438
1439     >>> is_valid_date('1/2/2022')
1440     True
1441     >>> is_valid_date('christmas')
1442     True
1443     >>> is_valid_date('next wednesday')
1444     True
1445     >>> is_valid_date('xyzzy')
1446     False
1447     """
1448     import dateparse.dateparse_utils as dp
1449
1450     try:
1451         d = dp.DateParser()  # type: ignore
1452         _ = d.parse(in_str)
1453         return True
1454     except dp.ParseException:  # type: ignore
1455         msg = f'Unable to parse date {in_str}.'
1456         logger.warning(msg)
1457     return False
1458
1459
1460 def to_datetime(in_str: str) -> Optional[datetime.datetime]:
1461     """
1462     Args:
1463         in_str: string to parse into a datetime
1464
1465     Returns:
1466         A python datetime parsed from in_str or None to indicate
1467         an error.  This parser is relatively clever; see
1468         :class:`python_modules.dateparse.dateparse_utils` docs for
1469         details.
1470
1471     >>> to_datetime('7/20/1969 02:56 GMT')
1472     datetime.datetime(1969, 7, 20, 2, 56, tzinfo=<StaticTzInfo 'GMT'>)
1473     """
1474     import dateparse.dateparse_utils as dp
1475
1476     try:
1477         d = dp.DateParser()  # type: ignore
1478         dt = d.parse(in_str)
1479         if isinstance(dt, datetime.datetime):
1480             return dt
1481     except Exception:
1482         msg = f'Unable to parse datetime {in_str}.'
1483         logger.warning(msg)
1484     return None
1485
1486
1487 def valid_datetime(in_str: str) -> bool:
1488     """
1489     Args:
1490         in_str: the string to check
1491
1492     Returns:
1493         True if in_str contains a valid datetime and False otherwise.
1494         This parser is relatively clever; see
1495         :class:`python_modules.dateparse.dateparse_utils` docs for
1496         details.
1497
1498     >>> valid_datetime('next wednesday at noon')
1499     True
1500     >>> valid_datetime('3 weeks ago at midnight')
1501     True
1502     >>> valid_datetime('next easter at 5:00 am')
1503     True
1504     >>> valid_datetime('sometime soon')
1505     False
1506     """
1507     _ = to_datetime(in_str)
1508     if _ is not None:
1509         return True
1510     msg = f'Unable to parse datetime {in_str}.'
1511     logger.warning(msg)
1512     return False
1513
1514
1515 def squeeze(in_str: str, character_to_squeeze: str = ' ') -> str:
1516     """
1517     Args:
1518         in_str: the string to squeeze
1519         character_to_squeeze: the character to remove runs of
1520             more than one in a row (default = space)
1521
1522     Returns: A "squeezed string" where runs of more than one
1523         character_to_squeeze into one.
1524
1525     >>> squeeze(' this        is       a    test    ')
1526     ' this is a test '
1527
1528     >>> squeeze('one|!||!|two|!||!|three', character_to_squeeze='|!|')
1529     'one|!|two|!|three'
1530
1531     """
1532     return re.sub(
1533         r'(' + re.escape(character_to_squeeze) + r')+',
1534         character_to_squeeze,
1535         in_str,
1536     )
1537
1538
1539 def dedent(in_str: str) -> Optional[str]:
1540     """
1541     Args:
1542         in_str: the string to dedent
1543
1544     Returns:
1545         A string with tab indentation removed or None on error.
1546
1547     .. note::
1548
1549         Inspired by analogous Scala function.
1550
1551     >>> dedent('\t\ttest\\n\t\ting')
1552     'test\\ning'
1553     """
1554     if not is_string(in_str):
1555         return None
1556     line_separator = '\n'
1557     lines = [MARGIN_RE.sub('', line) for line in in_str.split(line_separator)]
1558     return line_separator.join(lines)
1559
1560
1561 def indent(in_str: str, amount: int) -> str:
1562     """
1563     Args:
1564         in_str: the string to indent
1565         amount: count of spaces to indent each line by
1566
1567     Returns:
1568         An indented string created by prepending amount spaces.
1569
1570     >>> indent('This is a test', 4)
1571     '    This is a test'
1572     """
1573     if not is_string(in_str):
1574         raise ValueError(in_str)
1575     line_separator = '\n'
1576     lines = [" " * amount + line for line in in_str.split(line_separator)]
1577     return line_separator.join(lines)
1578
1579
1580 def sprintf(*args, **kwargs) -> str:
1581     """
1582     Args:
1583         This function uses the same syntax as the builtin print
1584         function.
1585
1586     Returns:
1587         An interpolated string capturing print output, like man(3)
1588         :code:sprintf.
1589     """
1590     ret = ""
1591
1592     sep = kwargs.pop("sep", None)
1593     if sep is not None:
1594         if not isinstance(sep, str):
1595             raise TypeError("sep must be None or a string")
1596
1597     end = kwargs.pop("end", None)
1598     if end is not None:
1599         if not isinstance(end, str):
1600             raise TypeError("end must be None or a string")
1601
1602     if kwargs:
1603         raise TypeError("invalid keyword arguments to sprint()")
1604
1605     if sep is None:
1606         sep = " "
1607     if end is None:
1608         end = "\n"
1609     for i, arg in enumerate(args):
1610         if i:
1611             ret += sep
1612         if isinstance(arg, str):
1613             ret += arg
1614         else:
1615             ret += str(arg)
1616     ret += end
1617     return ret
1618
1619
1620 def strip_ansi_sequences(in_str: str) -> str:
1621     """
1622     Args:
1623         in_str: the string to strip
1624
1625     Returns:
1626         in_str with recognized ANSI escape sequences removed.
1627
1628     .. warning::
1629         This method works by using a regular expression.
1630         It works for all ANSI escape sequences I've tested with but
1631         may miss some; caveat emptor.
1632
1633     >>> import ansi as a
1634     >>> s = a.fg('blue') + 'blue!' + a.reset()
1635     >>> len(s)   # '\x1b[38;5;21mblue!\x1b[m'
1636     18
1637     >>> len(strip_ansi_sequences(s))
1638     5
1639     >>> strip_ansi_sequences(s)
1640     'blue!'
1641
1642     """
1643     return re.sub(r'\x1b\[[\d+;]*[a-z]', '', in_str)
1644
1645
1646 class SprintfStdout(contextlib.AbstractContextManager):
1647     """
1648     A context manager that captures outputs to stdout to a buffer
1649     without printing them.
1650
1651     >>> with SprintfStdout() as buf:
1652     ...     print("test")
1653     ...     print("1, 2, 3")
1654     ...
1655     >>> print(buf(), end='')
1656     test
1657     1, 2, 3
1658
1659     """
1660
1661     def __init__(self) -> None:
1662         self.destination = io.StringIO()
1663         self.recorder: contextlib.redirect_stdout
1664
1665     def __enter__(self) -> Callable[[], str]:
1666         self.recorder = contextlib.redirect_stdout(self.destination)
1667         self.recorder.__enter__()
1668         return lambda: self.destination.getvalue()
1669
1670     def __exit__(self, *args) -> Literal[False]:
1671         self.recorder.__exit__(*args)
1672         self.destination.seek(0)
1673         return False
1674
1675
1676 def capitalize_first_letter(in_str: str) -> str:
1677     """
1678     Args:
1679         in_str: the string to capitalize
1680
1681     Returns:
1682         in_str with the first character capitalized.
1683
1684     >>> capitalize_first_letter('test')
1685     'Test'
1686     >>> capitalize_first_letter("ALREADY!")
1687     'ALREADY!'
1688
1689     """
1690     return in_str[0].upper() + in_str[1:]
1691
1692
1693 def it_they(n: int) -> str:
1694     """
1695     Args:
1696         n: how many of them are there?
1697
1698     Returns:
1699         'it' if n is one or 'they' otherwize.
1700
1701     Suggested usage::
1702
1703         n = num_files_saved_to_tmp()
1704         print(f'Saved file{pluralize(n)} successfully.')
1705         print(f'{it_they(n)} {is_are(n)} located in /tmp.')
1706
1707     >>> it_they(1)
1708     'it'
1709     >>> it_they(100)
1710     'they'
1711     """
1712     if n == 1:
1713         return "it"
1714     return "they"
1715
1716
1717 def is_are(n: int) -> str:
1718     """
1719     Args:
1720         n: how many of them are there?
1721
1722     Returns:
1723         'is' if n is one or 'are' otherwize.
1724
1725     Suggested usage::
1726
1727         n = num_files_saved_to_tmp()
1728         print(f'Saved file{pluralize(n)} successfully.')
1729         print(f'{it_they(n)} {is_are(n)} located in /tmp.')
1730
1731     >>> is_are(1)
1732     'is'
1733     >>> is_are(2)
1734     'are'
1735
1736     """
1737     if n == 1:
1738         return "is"
1739     return "are"
1740
1741
1742 def pluralize(n: int) -> str:
1743     """
1744     Args:
1745         n: how many of them are there?
1746
1747     Returns:
1748         's' if n is greater than one otherwize ''.
1749
1750     Suggested usage::
1751
1752         n = num_files_saved_to_tmp()
1753         print(f'Saved file{pluralize(n)} successfully.')
1754         print(f'{it_they(n)} {is_are(n)} located in /tmp.')
1755
1756     >>> pluralize(15)
1757     's'
1758     >>> count = 1
1759     >>> print(f'There {is_are(count)} {count} file{pluralize(count)}.')
1760     There is 1 file.
1761     >>> count = 4
1762     >>> print(f'There {is_are(count)} {count} file{pluralize(count)}.')
1763     There are 4 files.
1764     """
1765     if n == 1:
1766         return ""
1767     return "s"
1768
1769
1770 def make_contractions(txt: str) -> str:
1771     """This code glues words in txt together to form (English)
1772     contractions.
1773
1774     Args:
1775         txt: the input text to be contractionized.
1776
1777     Returns:
1778         Output text identical to original input except for any
1779         recognized contractions are formed.
1780
1781     .. note::
1782         The order in which we create contractions is defined by the
1783         implementation and what I thought made more sense when writing
1784         this code.
1785
1786     >>> make_contractions('It is nice today.')
1787     "It's nice today."
1788
1789     >>> make_contractions('I can    not even...')
1790     "I can't even..."
1791
1792     >>> make_contractions('She could not see!')
1793     "She couldn't see!"
1794
1795     >>> make_contractions('But she will not go.')
1796     "But she won't go."
1797
1798     >>> make_contractions('Verily, I shall not.')
1799     "Verily, I shan't."
1800
1801     >>> make_contractions('No you cannot.')
1802     "No you can't."
1803
1804     >>> make_contractions('I said you can not go.')
1805     "I said you can't go."
1806     """
1807
1808     first_second = [
1809         (
1810             [
1811                 'are',
1812                 'could',
1813                 'did',
1814                 'has',
1815                 'have',
1816                 'is',
1817                 'must',
1818                 'should',
1819                 'was',
1820                 'were',
1821                 'would',
1822             ],
1823             ['(n)o(t)'],
1824         ),
1825         (
1826             [
1827                 "I",
1828                 "you",
1829                 "he",
1830                 "she",
1831                 "it",
1832                 "we",
1833                 "they",
1834                 "how",
1835                 "why",
1836                 "when",
1837                 "where",
1838                 "who",
1839                 "there",
1840             ],
1841             ['woul(d)', 'i(s)', 'a(re)', 'ha(s)', 'ha(ve)', 'ha(d)', 'wi(ll)'],
1842         ),
1843     ]
1844
1845     # Special cases: can't, shan't and won't.
1846     txt = re.sub(r'\b(can)\s*no(t)\b', r"\1'\2", txt, count=0, flags=re.IGNORECASE)
1847     txt = re.sub(r'\b(sha)ll\s*(n)o(t)\b', r"\1\2'\3", txt, count=0, flags=re.IGNORECASE)
1848     txt = re.sub(
1849         r'\b(w)ill\s*(n)(o)(t)\b',
1850         r"\1\3\2'\4",
1851         txt,
1852         count=0,
1853         flags=re.IGNORECASE,
1854     )
1855
1856     for first_list, second_list in first_second:
1857         for first in first_list:
1858             for second in second_list:
1859                 # Disallow there're/where're.  They're valid English
1860                 # but sound weird.
1861                 if (first in ('there', 'where')) and second == 'a(re)':
1862                     continue
1863
1864                 pattern = fr'\b({first})\s+{second}\b'
1865                 if second == '(n)o(t)':
1866                     replacement = r"\1\2'\3"
1867                 else:
1868                     replacement = r"\1'\2"
1869                 txt = re.sub(pattern, replacement, txt, count=0, flags=re.IGNORECASE)
1870
1871     return txt
1872
1873
1874 def thify(n: int) -> str:
1875     """
1876     Args:
1877         n: how many of them are there?
1878
1879     Returns:
1880         The proper cardinal suffix for a number.
1881
1882     Suggested usage::
1883
1884         attempt_count = 0
1885         while True:
1886             attempt_count += 1
1887             if try_the_thing():
1888                 break
1889             print(f'The {attempt_count}{thify(attempt_count)} failed, trying again.')
1890
1891     >>> thify(1)
1892     'st'
1893     >>> thify(33)
1894     'rd'
1895     >>> thify(16)
1896     'th'
1897     """
1898     digit = str(n)
1899     assert is_integer_number(digit)
1900     digit = digit[-1:]
1901     if digit == "1":
1902         return "st"
1903     elif digit == "2":
1904         return "nd"
1905     elif digit == "3":
1906         return "rd"
1907     else:
1908         return "th"
1909
1910
1911 def ngrams(txt: str, n: int):
1912     """
1913     Args:
1914         txt: the string to create ngrams using
1915         n: how many words per ngram created?
1916
1917     Returns:
1918         Generates the ngrams from the input string.
1919
1920     >>> [x for x in ngrams('This is a test', 2)]
1921     ['This is', 'is a', 'a test']
1922     """
1923     words = txt.split()
1924     for ngram in ngrams_presplit(words, n):
1925         ret = ''
1926         for word in ngram:
1927             ret += f'{word} '
1928         yield ret.strip()
1929
1930
1931 def ngrams_presplit(words: Sequence[str], n: int):
1932     """
1933     Same as :meth:`ngrams` but with the string pre-split.
1934     """
1935     return list_utils.ngrams(words, n)
1936
1937
1938 def bigrams(txt: str):
1939     """Generates the bigrams (n=2) of the given string."""
1940     return ngrams(txt, 2)
1941
1942
1943 def trigrams(txt: str):
1944     """Generates the trigrams (n=3) of the given string."""
1945     return ngrams(txt, 3)
1946
1947
1948 def shuffle_columns_into_list(
1949     input_lines: Sequence[str], column_specs: Iterable[Iterable[int]], delim=''
1950 ) -> Iterable[str]:
1951     """Helper to shuffle / parse columnar data and return the results as a
1952     list.
1953
1954     Args:
1955         input_lines: A sequence of strings that represents text that
1956             has been broken into columns by the caller
1957         column_specs: an iterable collection of numeric sequences that
1958             indicate one or more column numbers to copy to form the Nth
1959             position in the output list.  See example below.
1960         delim: for column_specs that indicate we should copy more than
1961             one column from the input into this position, use delim to
1962             separate source data.  Defaults to ''.
1963
1964     Returns:
1965         A list of string created by following the instructions set forth
1966         in column_specs.
1967
1968     >>> cols = '-rwxr-xr-x 1 scott wheel 3.1K Jul  9 11:34 acl_test.py'.split()
1969     >>> shuffle_columns_into_list(
1970     ...     cols,
1971     ...     [ [8], [2, 3], [5, 6, 7] ],
1972     ...     delim='!',
1973     ... )
1974     ['acl_test.py', 'scott!wheel', 'Jul!9!11:34']
1975     """
1976     out = []
1977
1978     # Column specs map input lines' columns into outputs.
1979     # [col1, col2...]
1980     for spec in column_specs:
1981         hunk = ''
1982         for n in spec:
1983             hunk = hunk + delim + input_lines[n]
1984         hunk = hunk.strip(delim)
1985         out.append(hunk)
1986     return out
1987
1988
1989 def shuffle_columns_into_dict(
1990     input_lines: Sequence[str],
1991     column_specs: Iterable[Tuple[str, Iterable[int]]],
1992     delim='',
1993 ) -> Dict[str, str]:
1994     """Helper to shuffle / parse columnar data and return the results
1995     as a dict.
1996
1997     Args:
1998         input_lines: a sequence of strings that represents text that
1999             has been broken into columns by the caller
2000         column_specs: instructions for what dictionary keys to apply
2001             to individual or compound input column data.  See example
2002             below.
2003         delim: when forming compound output data by gluing more than
2004             one input column together, use this character to separate
2005             the source data.  Defaults to ''.
2006
2007     Returns:
2008         A dict formed by applying the column_specs instructions.
2009
2010     >>> cols = '-rwxr-xr-x 1 scott wheel 3.1K Jul  9 11:34 acl_test.py'.split()
2011     >>> shuffle_columns_into_dict(
2012     ...     cols,
2013     ...     [ ('filename', [8]), ('owner', [2, 3]), ('mtime', [5, 6, 7]) ],
2014     ...     delim='!',
2015     ... )
2016     {'filename': 'acl_test.py', 'owner': 'scott!wheel', 'mtime': 'Jul!9!11:34'}
2017     """
2018     out = {}
2019
2020     # Column specs map input lines' columns into outputs.
2021     # "key", [col1, col2...]
2022     for spec in column_specs:
2023         hunk = ''
2024         for n in spec[1]:
2025             hunk = hunk + delim + input_lines[n]
2026         hunk = hunk.strip(delim)
2027         out[spec[0]] = hunk
2028     return out
2029
2030
2031 def interpolate_using_dict(txt: str, values: Dict[str, str]) -> str:
2032     """
2033     Interpolate a string with data from a dict.
2034
2035     Args:
2036         txt: the mad libs template
2037         values: what you and your kids chose for each category.
2038
2039     >>> interpolate_using_dict('This is a {adjective} {noun}.',
2040     ...                        {'adjective': 'good', 'noun': 'example'})
2041     'This is a good example.'
2042     """
2043     return sprintf(txt.format(**values), end='')
2044
2045
2046 def to_ascii(txt: str):
2047     """
2048     Args:
2049         txt: the input data to encode
2050
2051     Returns:
2052         txt encoded as an ASCII byte string.
2053
2054     >>> to_ascii('test')
2055     b'test'
2056
2057     >>> to_ascii(b'1, 2, 3')
2058     b'1, 2, 3'
2059     """
2060     if isinstance(txt, str):
2061         return txt.encode('ascii')
2062     if isinstance(txt, bytes):
2063         return txt
2064     raise Exception('to_ascii works with strings and bytes')
2065
2066
2067 def to_base64(txt: str, *, encoding='utf-8', errors='surrogatepass') -> bytes:
2068     """
2069     Args:
2070         txt: the input data to encode
2071
2072     Returns:
2073         txt encoded with a 64-chracter alphabet.  Similar to and compatible
2074         with uuencode/uudecode.
2075
2076     >>> to_base64('hello?')
2077     b'aGVsbG8/\\n'
2078     """
2079     return base64.encodebytes(txt.encode(encoding, errors))
2080
2081
2082 def is_base64(txt: str) -> bool:
2083     """
2084     Args:
2085         txt: the string to check
2086
2087     Returns:
2088         True if txt is a valid base64 encoded string.  This assumes
2089         txt was encoded with Python's standard base64 alphabet which
2090         is the same as what uuencode/uudecode uses).
2091
2092     >>> is_base64('test')    # all letters in the b64 alphabet
2093     True
2094
2095     >>> is_base64('another test, how do you like this one?')
2096     False
2097
2098     >>> is_base64(b'aGVsbG8/\\n')    # Ending newline is ok.
2099     True
2100
2101     """
2102     a = string.ascii_uppercase + string.ascii_lowercase + string.digits + '+/'
2103     alphabet = set(a.encode('ascii'))
2104     for char in to_ascii(txt.strip()):
2105         if char not in alphabet:
2106             return False
2107     return True
2108
2109
2110 def from_base64(b64: bytes, encoding='utf-8', errors='surrogatepass') -> str:
2111     """
2112     Args:
2113         b64: bytestring of 64-bit encoded data to decode / convert.
2114
2115     Returns:
2116         The decoded form of b64 as a normal python string.  Similar to
2117         and compatible with uuencode / uudecode.
2118
2119     >>> from_base64(b'aGVsbG8/\\n')
2120     'hello?'
2121     """
2122     return base64.decodebytes(b64).decode(encoding, errors)
2123
2124
2125 def chunk(txt: str, chunk_size: int):
2126     """
2127     Args:
2128         txt: a string to be chunked into evenly spaced pieces.
2129         chunk_size: the size of each chunk to make
2130
2131     Returns:
2132         The original string chunked into evenly spaced pieces.
2133
2134     >>> ' '.join(chunk('010011011100010110101010101010101001111110101000', 8))
2135     '01001101 11000101 10101010 10101010 10011111 10101000'
2136     """
2137     if len(txt) % chunk_size != 0:
2138         msg = f'String to chunk\'s length ({len(txt)} is not an even multiple of chunk_size ({chunk_size})'
2139         logger.warning(msg)
2140         warnings.warn(msg, stacklevel=2)
2141     for x in range(0, len(txt), chunk_size):
2142         yield txt[x : x + chunk_size]
2143
2144
2145 def to_bitstring(txt: str, *, delimiter='') -> str:
2146     """
2147     Args:
2148         txt: the string to convert into a bitstring
2149         delimiter: character to insert between adjacent bytes.  Note that
2150             only bitstrings with delimiter='' are interpretable by
2151             :meth:`from_bitstring`.
2152
2153     Returns:
2154         txt converted to ascii/binary and then chopped into bytes.
2155
2156     >>> to_bitstring('hello?')
2157     '011010000110010101101100011011000110111100111111'
2158
2159     >>> to_bitstring('test', delimiter=' ')
2160     '01110100 01100101 01110011 01110100'
2161
2162     >>> to_bitstring(b'test')
2163     '01110100011001010111001101110100'
2164     """
2165     etxt = to_ascii(txt)
2166     bits = bin(int.from_bytes(etxt, 'big'))
2167     bits = bits[2:]
2168     return delimiter.join(chunk(bits.zfill(8 * ((len(bits) + 7) // 8)), 8))
2169
2170
2171 def is_bitstring(txt: str) -> bool:
2172     """
2173     Args:
2174         txt: the string to check
2175
2176     Returns:
2177         True if txt is a recognized bitstring and False otherwise.
2178         Note that if delimiter is non empty this code will not
2179         recognize the bitstring.
2180
2181     >>> is_bitstring('011010000110010101101100011011000110111100111111')
2182     True
2183
2184     >>> is_bitstring('1234')
2185     False
2186     """
2187     return is_binary_integer_number(f'0b{txt}')
2188
2189
2190 def from_bitstring(bits: str, encoding='utf-8', errors='surrogatepass') -> str:
2191     """
2192     Args:
2193         bits: the bitstring to convert back into a python string
2194         encoding: the encoding to use
2195
2196     Returns:
2197         The regular python string represented by bits.  Note that this
2198         code does not work with to_bitstring when delimiter is non-empty.
2199
2200     >>> from_bitstring('011010000110010101101100011011000110111100111111')
2201     'hello?'
2202     """
2203     n = int(bits, 2)
2204     return n.to_bytes((n.bit_length() + 7) // 8, 'big').decode(encoding, errors) or '\0'
2205
2206
2207 def ip_v4_sort_key(txt: str) -> Optional[Tuple[int, ...]]:
2208     """
2209     Args:
2210         txt: an IP address to chunk up for sorting purposes
2211
2212     Returns:
2213         A tuple of IP components arranged such that the sorting of
2214         IP addresses using a normal comparator will do something sane
2215         and desireable.
2216
2217     >>> ip_v4_sort_key('10.0.0.18')
2218     (10, 0, 0, 18)
2219
2220     >>> ips = ['10.0.0.10', '100.0.0.1', '1.2.3.4', '10.0.0.9']
2221     >>> sorted(ips, key=lambda x: ip_v4_sort_key(x))
2222     ['1.2.3.4', '10.0.0.9', '10.0.0.10', '100.0.0.1']
2223     """
2224     if not is_ip_v4(txt):
2225         print(f"not IP: {txt}")
2226         return None
2227     return tuple(int(x) for x in txt.split('.'))
2228
2229
2230 def path_ancestors_before_descendants_sort_key(volume: str) -> Tuple[str, ...]:
2231     """
2232     Args:
2233         volume: the string to chunk up for sorting purposes
2234
2235     Returns:
2236         A tuple of volume's components such that the sorting of
2237         volumes using a normal comparator will do something sane
2238         and desireable.
2239
2240     >>> path_ancestors_before_descendants_sort_key('/usr/local/bin')
2241     ('usr', 'local', 'bin')
2242
2243     >>> paths = ['/usr/local', '/usr/local/bin', '/usr']
2244     >>> sorted(paths, key=lambda x: path_ancestors_before_descendants_sort_key(x))
2245     ['/usr', '/usr/local', '/usr/local/bin']
2246     """
2247     return tuple(x for x in volume.split('/') if len(x) > 0)
2248
2249
2250 def replace_all(in_str: str, replace_set: str, replacement: str) -> str:
2251     """
2252     Execute several replace operations in a row.
2253
2254     Args:
2255         in_str: the string in which to replace characters
2256         replace_set: the set of target characters to replace
2257         replacement: the character to replace any member of replace_set
2258             with
2259
2260     Returns:
2261         The string with replacements executed.
2262
2263     >>> s = 'this_is a-test!'
2264     >>> replace_all(s, ' _-!', '')
2265     'thisisatest'
2266     """
2267     for char in replace_set:
2268         in_str = in_str.replace(char, replacement)
2269     return in_str
2270
2271
2272 def replace_nth(in_str: str, source: str, target: str, nth: int):
2273     """
2274     Replaces the nth occurrance of a substring within a string.
2275
2276     Args:
2277         in_str: the string in which to run the replacement
2278         source: the substring to replace
2279         target: the replacement text
2280         nth: which occurrance of source to replace?
2281
2282     >>> replace_nth('this is a test', ' ', '-', 3)
2283     'this is a-test'
2284     """
2285     where = [m.start() for m in re.finditer(source, in_str)][nth - 1]
2286     before = in_str[:where]
2287     after = in_str[where:]
2288     after = after.replace(source, target, 1)
2289     return before + after
2290
2291
2292 if __name__ == '__main__':
2293     import doctest
2294
2295     doctest.testmod()