Improve dateparse and make string_utils.extract_date.
[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[str]:
1392     import itertools
1393
1394     import dateparse.dateparse_utils as du
1395
1396     d = du.DateParser()  # type: ignore
1397     chunks = in_str.split()
1398     for ngram in itertools.chain(
1399         list_utils.ngrams(chunks, 5),
1400         list_utils.ngrams(chunks, 4),
1401         list_utils.ngrams(chunks, 3),
1402         list_utils.ngrams(chunks, 2),
1403     ):
1404         try:
1405             expr = " ".join(ngram)
1406             logger.debug(f"Trying {expr}")
1407             if d.parse(expr):
1408                 return d.get_date()
1409         except du.ParseException:  # type: ignore
1410             pass
1411     return None
1412
1413
1414 def is_valid_date(in_str: str) -> bool:
1415     """
1416     Args:
1417         in_str: the string to check
1418
1419     Returns:
1420         True if the string represents a valid date that we can recognize
1421         and False otherwise.  This parser is relatively clever; see
1422         :class:`python_modules.dateparse.dateparse_utils` docs for
1423         details.
1424
1425     >>> is_valid_date('1/2/2022')
1426     True
1427     >>> is_valid_date('christmas')
1428     True
1429     >>> is_valid_date('next wednesday')
1430     True
1431     >>> is_valid_date('xyzzy')
1432     False
1433     """
1434     import dateparse.dateparse_utils as dp
1435
1436     try:
1437         d = dp.DateParser()  # type: ignore
1438         _ = d.parse(in_str)
1439         return True
1440     except dp.ParseException:  # type: ignore
1441         msg = f'Unable to parse date {in_str}.'
1442         logger.warning(msg)
1443     return False
1444
1445
1446 def to_datetime(in_str: str) -> Optional[datetime.datetime]:
1447     """
1448     Args:
1449         in_str: string to parse into a datetime
1450
1451     Returns:
1452         A python datetime parsed from in_str or None to indicate
1453         an error.  This parser is relatively clever; see
1454         :class:`python_modules.dateparse.dateparse_utils` docs for
1455         details.
1456
1457     >>> to_datetime('7/20/1969 02:56 GMT')
1458     datetime.datetime(1969, 7, 20, 2, 56, tzinfo=<StaticTzInfo 'GMT'>)
1459     """
1460     import dateparse.dateparse_utils as dp
1461
1462     try:
1463         d = dp.DateParser()  # type: ignore
1464         dt = d.parse(in_str)
1465         if isinstance(dt, datetime.datetime):
1466             return dt
1467     except Exception:
1468         msg = f'Unable to parse datetime {in_str}.'
1469         logger.warning(msg)
1470     return None
1471
1472
1473 def valid_datetime(in_str: str) -> bool:
1474     """
1475     Args:
1476         in_str: the string to check
1477
1478     Returns:
1479         True if in_str contains a valid datetime and False otherwise.
1480         This parser is relatively clever; see
1481         :class:`python_modules.dateparse.dateparse_utils` docs for
1482         details.
1483
1484     >>> valid_datetime('next wednesday at noon')
1485     True
1486     >>> valid_datetime('3 weeks ago at midnight')
1487     True
1488     >>> valid_datetime('next easter at 5:00 am')
1489     True
1490     >>> valid_datetime('sometime soon')
1491     False
1492     """
1493     _ = to_datetime(in_str)
1494     if _ is not None:
1495         return True
1496     msg = f'Unable to parse datetime {in_str}.'
1497     logger.warning(msg)
1498     return False
1499
1500
1501 def squeeze(in_str: str, character_to_squeeze: str = ' ') -> str:
1502     """
1503     Args:
1504         in_str: the string to squeeze
1505         character_to_squeeze: the character to remove runs of
1506             more than one in a row (default = space)
1507
1508     Returns: A "squeezed string" where runs of more than one
1509         character_to_squeeze into one.
1510
1511     >>> squeeze(' this        is       a    test    ')
1512     ' this is a test '
1513
1514     >>> squeeze('one|!||!|two|!||!|three', character_to_squeeze='|!|')
1515     'one|!|two|!|three'
1516
1517     """
1518     return re.sub(
1519         r'(' + re.escape(character_to_squeeze) + r')+',
1520         character_to_squeeze,
1521         in_str,
1522     )
1523
1524
1525 def dedent(in_str: str) -> Optional[str]:
1526     """
1527     Args:
1528         in_str: the string to dedent
1529
1530     Returns:
1531         A string with tab indentation removed or None on error.
1532
1533     .. note::
1534
1535         Inspired by analogous Scala function.
1536
1537     >>> dedent('\t\ttest\\n\t\ting')
1538     'test\\ning'
1539     """
1540     if not is_string(in_str):
1541         return None
1542     line_separator = '\n'
1543     lines = [MARGIN_RE.sub('', line) for line in in_str.split(line_separator)]
1544     return line_separator.join(lines)
1545
1546
1547 def indent(in_str: str, amount: int) -> str:
1548     """
1549     Args:
1550         in_str: the string to indent
1551         amount: count of spaces to indent each line by
1552
1553     Returns:
1554         An indented string created by prepending amount spaces.
1555
1556     >>> indent('This is a test', 4)
1557     '    This is a test'
1558     """
1559     if not is_string(in_str):
1560         raise ValueError(in_str)
1561     line_separator = '\n'
1562     lines = [" " * amount + line for line in in_str.split(line_separator)]
1563     return line_separator.join(lines)
1564
1565
1566 def sprintf(*args, **kwargs) -> str:
1567     """
1568     Args:
1569         This function uses the same syntax as the builtin print
1570         function.
1571
1572     Returns:
1573         An interpolated string capturing print output, like man(3)
1574         :code:sprintf.
1575     """
1576     ret = ""
1577
1578     sep = kwargs.pop("sep", None)
1579     if sep is not None:
1580         if not isinstance(sep, str):
1581             raise TypeError("sep must be None or a string")
1582
1583     end = kwargs.pop("end", None)
1584     if end is not None:
1585         if not isinstance(end, str):
1586             raise TypeError("end must be None or a string")
1587
1588     if kwargs:
1589         raise TypeError("invalid keyword arguments to sprint()")
1590
1591     if sep is None:
1592         sep = " "
1593     if end is None:
1594         end = "\n"
1595     for i, arg in enumerate(args):
1596         if i:
1597             ret += sep
1598         if isinstance(arg, str):
1599             ret += arg
1600         else:
1601             ret += str(arg)
1602     ret += end
1603     return ret
1604
1605
1606 def strip_ansi_sequences(in_str: str) -> str:
1607     """
1608     Args:
1609         in_str: the string to strip
1610
1611     Returns:
1612         in_str with recognized ANSI escape sequences removed.
1613
1614     .. warning::
1615         This method works by using a regular expression.
1616         It works for all ANSI escape sequences I've tested with but
1617         may miss some; caveat emptor.
1618
1619     >>> import ansi as a
1620     >>> s = a.fg('blue') + 'blue!' + a.reset()
1621     >>> len(s)   # '\x1b[38;5;21mblue!\x1b[m'
1622     18
1623     >>> len(strip_ansi_sequences(s))
1624     5
1625     >>> strip_ansi_sequences(s)
1626     'blue!'
1627
1628     """
1629     return re.sub(r'\x1b\[[\d+;]*[a-z]', '', in_str)
1630
1631
1632 class SprintfStdout(contextlib.AbstractContextManager):
1633     """
1634     A context manager that captures outputs to stdout to a buffer
1635     without printing them.
1636
1637     >>> with SprintfStdout() as buf:
1638     ...     print("test")
1639     ...     print("1, 2, 3")
1640     ...
1641     >>> print(buf(), end='')
1642     test
1643     1, 2, 3
1644
1645     """
1646
1647     def __init__(self) -> None:
1648         self.destination = io.StringIO()
1649         self.recorder: contextlib.redirect_stdout
1650
1651     def __enter__(self) -> Callable[[], str]:
1652         self.recorder = contextlib.redirect_stdout(self.destination)
1653         self.recorder.__enter__()
1654         return lambda: self.destination.getvalue()
1655
1656     def __exit__(self, *args) -> Literal[False]:
1657         self.recorder.__exit__(*args)
1658         self.destination.seek(0)
1659         return False
1660
1661
1662 def capitalize_first_letter(in_str: str) -> str:
1663     """
1664     Args:
1665         in_str: the string to capitalize
1666
1667     Returns:
1668         in_str with the first character capitalized.
1669
1670     >>> capitalize_first_letter('test')
1671     'Test'
1672     >>> capitalize_first_letter("ALREADY!")
1673     'ALREADY!'
1674
1675     """
1676     return in_str[0].upper() + in_str[1:]
1677
1678
1679 def it_they(n: int) -> str:
1680     """
1681     Args:
1682         n: how many of them are there?
1683
1684     Returns:
1685         'it' if n is one or 'they' otherwize.
1686
1687     Suggested usage::
1688
1689         n = num_files_saved_to_tmp()
1690         print(f'Saved file{pluralize(n)} successfully.')
1691         print(f'{it_they(n)} {is_are(n)} located in /tmp.')
1692
1693     >>> it_they(1)
1694     'it'
1695     >>> it_they(100)
1696     'they'
1697     """
1698     if n == 1:
1699         return "it"
1700     return "they"
1701
1702
1703 def is_are(n: int) -> str:
1704     """
1705     Args:
1706         n: how many of them are there?
1707
1708     Returns:
1709         'is' if n is one or 'are' otherwize.
1710
1711     Suggested usage::
1712
1713         n = num_files_saved_to_tmp()
1714         print(f'Saved file{pluralize(n)} successfully.')
1715         print(f'{it_they(n)} {is_are(n)} located in /tmp.')
1716
1717     >>> is_are(1)
1718     'is'
1719     >>> is_are(2)
1720     'are'
1721
1722     """
1723     if n == 1:
1724         return "is"
1725     return "are"
1726
1727
1728 def pluralize(n: int) -> str:
1729     """
1730     Args:
1731         n: how many of them are there?
1732
1733     Returns:
1734         's' if n is greater than one otherwize ''.
1735
1736     Suggested usage::
1737
1738         n = num_files_saved_to_tmp()
1739         print(f'Saved file{pluralize(n)} successfully.')
1740         print(f'{it_they(n)} {is_are(n)} located in /tmp.')
1741
1742     >>> pluralize(15)
1743     's'
1744     >>> count = 1
1745     >>> print(f'There {is_are(count)} {count} file{pluralize(count)}.')
1746     There is 1 file.
1747     >>> count = 4
1748     >>> print(f'There {is_are(count)} {count} file{pluralize(count)}.')
1749     There are 4 files.
1750     """
1751     if n == 1:
1752         return ""
1753     return "s"
1754
1755
1756 def make_contractions(txt: str) -> str:
1757     """This code glues words in txt together to form (English)
1758     contractions.
1759
1760     Args:
1761         txt: the input text to be contractionized.
1762
1763     Returns:
1764         Output text identical to original input except for any
1765         recognized contractions are formed.
1766
1767     .. note::
1768         The order in which we create contractions is defined by the
1769         implementation and what I thought made more sense when writing
1770         this code.
1771
1772     >>> make_contractions('It is nice today.')
1773     "It's nice today."
1774
1775     >>> make_contractions('I can    not even...')
1776     "I can't even..."
1777
1778     >>> make_contractions('She could not see!')
1779     "She couldn't see!"
1780
1781     >>> make_contractions('But she will not go.')
1782     "But she won't go."
1783
1784     >>> make_contractions('Verily, I shall not.')
1785     "Verily, I shan't."
1786
1787     >>> make_contractions('No you cannot.')
1788     "No you can't."
1789
1790     >>> make_contractions('I said you can not go.')
1791     "I said you can't go."
1792     """
1793
1794     first_second = [
1795         (
1796             [
1797                 'are',
1798                 'could',
1799                 'did',
1800                 'has',
1801                 'have',
1802                 'is',
1803                 'must',
1804                 'should',
1805                 'was',
1806                 'were',
1807                 'would',
1808             ],
1809             ['(n)o(t)'],
1810         ),
1811         (
1812             [
1813                 "I",
1814                 "you",
1815                 "he",
1816                 "she",
1817                 "it",
1818                 "we",
1819                 "they",
1820                 "how",
1821                 "why",
1822                 "when",
1823                 "where",
1824                 "who",
1825                 "there",
1826             ],
1827             ['woul(d)', 'i(s)', 'a(re)', 'ha(s)', 'ha(ve)', 'ha(d)', 'wi(ll)'],
1828         ),
1829     ]
1830
1831     # Special cases: can't, shan't and won't.
1832     txt = re.sub(r'\b(can)\s*no(t)\b', r"\1'\2", txt, count=0, flags=re.IGNORECASE)
1833     txt = re.sub(r'\b(sha)ll\s*(n)o(t)\b', r"\1\2'\3", txt, count=0, flags=re.IGNORECASE)
1834     txt = re.sub(
1835         r'\b(w)ill\s*(n)(o)(t)\b',
1836         r"\1\3\2'\4",
1837         txt,
1838         count=0,
1839         flags=re.IGNORECASE,
1840     )
1841
1842     for first_list, second_list in first_second:
1843         for first in first_list:
1844             for second in second_list:
1845                 # Disallow there're/where're.  They're valid English
1846                 # but sound weird.
1847                 if (first in ('there', 'where')) and second == 'a(re)':
1848                     continue
1849
1850                 pattern = fr'\b({first})\s+{second}\b'
1851                 if second == '(n)o(t)':
1852                     replacement = r"\1\2'\3"
1853                 else:
1854                     replacement = r"\1'\2"
1855                 txt = re.sub(pattern, replacement, txt, count=0, flags=re.IGNORECASE)
1856
1857     return txt
1858
1859
1860 def thify(n: int) -> str:
1861     """
1862     Args:
1863         n: how many of them are there?
1864
1865     Returns:
1866         The proper cardinal suffix for a number.
1867
1868     Suggested usage::
1869
1870         attempt_count = 0
1871         while True:
1872             attempt_count += 1
1873             if try_the_thing():
1874                 break
1875             print(f'The {attempt_count}{thify(attempt_count)} failed, trying again.')
1876
1877     >>> thify(1)
1878     'st'
1879     >>> thify(33)
1880     'rd'
1881     >>> thify(16)
1882     'th'
1883     """
1884     digit = str(n)
1885     assert is_integer_number(digit)
1886     digit = digit[-1:]
1887     if digit == "1":
1888         return "st"
1889     elif digit == "2":
1890         return "nd"
1891     elif digit == "3":
1892         return "rd"
1893     else:
1894         return "th"
1895
1896
1897 def ngrams(txt: str, n: int):
1898     """
1899     Args:
1900         txt: the string to create ngrams using
1901         n: how many words per ngram created?
1902
1903     Returns:
1904         Generates the ngrams from the input string.
1905
1906     >>> [x for x in ngrams('This is a test', 2)]
1907     ['This is', 'is a', 'a test']
1908     """
1909     words = txt.split()
1910     for ngram in ngrams_presplit(words, n):
1911         ret = ''
1912         for word in ngram:
1913             ret += f'{word} '
1914         yield ret.strip()
1915
1916
1917 def ngrams_presplit(words: Sequence[str], n: int):
1918     """
1919     Same as :meth:`ngrams` but with the string pre-split.
1920     """
1921     return list_utils.ngrams(words, n)
1922
1923
1924 def bigrams(txt: str):
1925     """Generates the bigrams (n=2) of the given string."""
1926     return ngrams(txt, 2)
1927
1928
1929 def trigrams(txt: str):
1930     """Generates the trigrams (n=3) of the given string."""
1931     return ngrams(txt, 3)
1932
1933
1934 def shuffle_columns_into_list(
1935     input_lines: Sequence[str], column_specs: Iterable[Iterable[int]], delim=''
1936 ) -> Iterable[str]:
1937     """Helper to shuffle / parse columnar data and return the results as a
1938     list.
1939
1940     Args:
1941         input_lines: A sequence of strings that represents text that
1942             has been broken into columns by the caller
1943         column_specs: an iterable collection of numeric sequences that
1944             indicate one or more column numbers to copy to form the Nth
1945             position in the output list.  See example below.
1946         delim: for column_specs that indicate we should copy more than
1947             one column from the input into this position, use delim to
1948             separate source data.  Defaults to ''.
1949
1950     Returns:
1951         A list of string created by following the instructions set forth
1952         in column_specs.
1953
1954     >>> cols = '-rwxr-xr-x 1 scott wheel 3.1K Jul  9 11:34 acl_test.py'.split()
1955     >>> shuffle_columns_into_list(
1956     ...     cols,
1957     ...     [ [8], [2, 3], [5, 6, 7] ],
1958     ...     delim='!',
1959     ... )
1960     ['acl_test.py', 'scott!wheel', 'Jul!9!11:34']
1961     """
1962     out = []
1963
1964     # Column specs map input lines' columns into outputs.
1965     # [col1, col2...]
1966     for spec in column_specs:
1967         hunk = ''
1968         for n in spec:
1969             hunk = hunk + delim + input_lines[n]
1970         hunk = hunk.strip(delim)
1971         out.append(hunk)
1972     return out
1973
1974
1975 def shuffle_columns_into_dict(
1976     input_lines: Sequence[str],
1977     column_specs: Iterable[Tuple[str, Iterable[int]]],
1978     delim='',
1979 ) -> Dict[str, str]:
1980     """Helper to shuffle / parse columnar data and return the results
1981     as a dict.
1982
1983     Args:
1984         input_lines: a sequence of strings that represents text that
1985             has been broken into columns by the caller
1986         column_specs: instructions for what dictionary keys to apply
1987             to individual or compound input column data.  See example
1988             below.
1989         delim: when forming compound output data by gluing more than
1990             one input column together, use this character to separate
1991             the source data.  Defaults to ''.
1992
1993     Returns:
1994         A dict formed by applying the column_specs instructions.
1995
1996     >>> cols = '-rwxr-xr-x 1 scott wheel 3.1K Jul  9 11:34 acl_test.py'.split()
1997     >>> shuffle_columns_into_dict(
1998     ...     cols,
1999     ...     [ ('filename', [8]), ('owner', [2, 3]), ('mtime', [5, 6, 7]) ],
2000     ...     delim='!',
2001     ... )
2002     {'filename': 'acl_test.py', 'owner': 'scott!wheel', 'mtime': 'Jul!9!11:34'}
2003     """
2004     out = {}
2005
2006     # Column specs map input lines' columns into outputs.
2007     # "key", [col1, col2...]
2008     for spec in column_specs:
2009         hunk = ''
2010         for n in spec[1]:
2011             hunk = hunk + delim + input_lines[n]
2012         hunk = hunk.strip(delim)
2013         out[spec[0]] = hunk
2014     return out
2015
2016
2017 def interpolate_using_dict(txt: str, values: Dict[str, str]) -> str:
2018     """
2019     Interpolate a string with data from a dict.
2020
2021     Args:
2022         txt: the mad libs template
2023         values: what you and your kids chose for each category.
2024
2025     >>> interpolate_using_dict('This is a {adjective} {noun}.',
2026     ...                        {'adjective': 'good', 'noun': 'example'})
2027     'This is a good example.'
2028     """
2029     return sprintf(txt.format(**values), end='')
2030
2031
2032 def to_ascii(txt: str):
2033     """
2034     Args:
2035         txt: the input data to encode
2036
2037     Returns:
2038         txt encoded as an ASCII byte string.
2039
2040     >>> to_ascii('test')
2041     b'test'
2042
2043     >>> to_ascii(b'1, 2, 3')
2044     b'1, 2, 3'
2045     """
2046     if isinstance(txt, str):
2047         return txt.encode('ascii')
2048     if isinstance(txt, bytes):
2049         return txt
2050     raise Exception('to_ascii works with strings and bytes')
2051
2052
2053 def to_base64(txt: str, *, encoding='utf-8', errors='surrogatepass') -> bytes:
2054     """
2055     Args:
2056         txt: the input data to encode
2057
2058     Returns:
2059         txt encoded with a 64-chracter alphabet.  Similar to and compatible
2060         with uuencode/uudecode.
2061
2062     >>> to_base64('hello?')
2063     b'aGVsbG8/\\n'
2064     """
2065     return base64.encodebytes(txt.encode(encoding, errors))
2066
2067
2068 def is_base64(txt: str) -> bool:
2069     """
2070     Args:
2071         txt: the string to check
2072
2073     Returns:
2074         True if txt is a valid base64 encoded string.  This assumes
2075         txt was encoded with Python's standard base64 alphabet which
2076         is the same as what uuencode/uudecode uses).
2077
2078     >>> is_base64('test')    # all letters in the b64 alphabet
2079     True
2080
2081     >>> is_base64('another test, how do you like this one?')
2082     False
2083
2084     >>> is_base64(b'aGVsbG8/\\n')    # Ending newline is ok.
2085     True
2086
2087     """
2088     a = string.ascii_uppercase + string.ascii_lowercase + string.digits + '+/'
2089     alphabet = set(a.encode('ascii'))
2090     for char in to_ascii(txt.strip()):
2091         if char not in alphabet:
2092             return False
2093     return True
2094
2095
2096 def from_base64(b64: bytes, encoding='utf-8', errors='surrogatepass') -> str:
2097     """
2098     Args:
2099         b64: bytestring of 64-bit encoded data to decode / convert.
2100
2101     Returns:
2102         The decoded form of b64 as a normal python string.  Similar to
2103         and compatible with uuencode / uudecode.
2104
2105     >>> from_base64(b'aGVsbG8/\\n')
2106     'hello?'
2107     """
2108     return base64.decodebytes(b64).decode(encoding, errors)
2109
2110
2111 def chunk(txt: str, chunk_size: int):
2112     """
2113     Args:
2114         txt: a string to be chunked into evenly spaced pieces.
2115         chunk_size: the size of each chunk to make
2116
2117     Returns:
2118         The original string chunked into evenly spaced pieces.
2119
2120     >>> ' '.join(chunk('010011011100010110101010101010101001111110101000', 8))
2121     '01001101 11000101 10101010 10101010 10011111 10101000'
2122     """
2123     if len(txt) % chunk_size != 0:
2124         msg = f'String to chunk\'s length ({len(txt)} is not an even multiple of chunk_size ({chunk_size})'
2125         logger.warning(msg)
2126         warnings.warn(msg, stacklevel=2)
2127     for x in range(0, len(txt), chunk_size):
2128         yield txt[x : x + chunk_size]
2129
2130
2131 def to_bitstring(txt: str, *, delimiter='') -> str:
2132     """
2133     Args:
2134         txt: the string to convert into a bitstring
2135         delimiter: character to insert between adjacent bytes.  Note that
2136             only bitstrings with delimiter='' are interpretable by
2137             :meth:`from_bitstring`.
2138
2139     Returns:
2140         txt converted to ascii/binary and then chopped into bytes.
2141
2142     >>> to_bitstring('hello?')
2143     '011010000110010101101100011011000110111100111111'
2144
2145     >>> to_bitstring('test', delimiter=' ')
2146     '01110100 01100101 01110011 01110100'
2147
2148     >>> to_bitstring(b'test')
2149     '01110100011001010111001101110100'
2150     """
2151     etxt = to_ascii(txt)
2152     bits = bin(int.from_bytes(etxt, 'big'))
2153     bits = bits[2:]
2154     return delimiter.join(chunk(bits.zfill(8 * ((len(bits) + 7) // 8)), 8))
2155
2156
2157 def is_bitstring(txt: str) -> bool:
2158     """
2159     Args:
2160         txt: the string to check
2161
2162     Returns:
2163         True if txt is a recognized bitstring and False otherwise.
2164         Note that if delimiter is non empty this code will not
2165         recognize the bitstring.
2166
2167     >>> is_bitstring('011010000110010101101100011011000110111100111111')
2168     True
2169
2170     >>> is_bitstring('1234')
2171     False
2172     """
2173     return is_binary_integer_number(f'0b{txt}')
2174
2175
2176 def from_bitstring(bits: str, encoding='utf-8', errors='surrogatepass') -> str:
2177     """
2178     Args:
2179         bits: the bitstring to convert back into a python string
2180         encoding: the encoding to use
2181
2182     Returns:
2183         The regular python string represented by bits.  Note that this
2184         code does not work with to_bitstring when delimiter is non-empty.
2185
2186     >>> from_bitstring('011010000110010101101100011011000110111100111111')
2187     'hello?'
2188     """
2189     n = int(bits, 2)
2190     return n.to_bytes((n.bit_length() + 7) // 8, 'big').decode(encoding, errors) or '\0'
2191
2192
2193 def ip_v4_sort_key(txt: str) -> Optional[Tuple[int, ...]]:
2194     """
2195     Args:
2196         txt: an IP address to chunk up for sorting purposes
2197
2198     Returns:
2199         A tuple of IP components arranged such that the sorting of
2200         IP addresses using a normal comparator will do something sane
2201         and desireable.
2202
2203     >>> ip_v4_sort_key('10.0.0.18')
2204     (10, 0, 0, 18)
2205
2206     >>> ips = ['10.0.0.10', '100.0.0.1', '1.2.3.4', '10.0.0.9']
2207     >>> sorted(ips, key=lambda x: ip_v4_sort_key(x))
2208     ['1.2.3.4', '10.0.0.9', '10.0.0.10', '100.0.0.1']
2209     """
2210     if not is_ip_v4(txt):
2211         print(f"not IP: {txt}")
2212         return None
2213     return tuple(int(x) for x in txt.split('.'))
2214
2215
2216 def path_ancestors_before_descendants_sort_key(volume: str) -> Tuple[str, ...]:
2217     """
2218     Args:
2219         volume: the string to chunk up for sorting purposes
2220
2221     Returns:
2222         A tuple of volume's components such that the sorting of
2223         volumes using a normal comparator will do something sane
2224         and desireable.
2225
2226     >>> path_ancestors_before_descendants_sort_key('/usr/local/bin')
2227     ('usr', 'local', 'bin')
2228
2229     >>> paths = ['/usr/local', '/usr/local/bin', '/usr']
2230     >>> sorted(paths, key=lambda x: path_ancestors_before_descendants_sort_key(x))
2231     ['/usr', '/usr/local', '/usr/local/bin']
2232     """
2233     return tuple(x for x in volume.split('/') if len(x) > 0)
2234
2235
2236 def replace_all(in_str: str, replace_set: str, replacement: str) -> str:
2237     """
2238     Execute several replace operations in a row.
2239
2240     Args:
2241         in_str: the string in which to replace characters
2242         replace_set: the set of target characters to replace
2243         replacement: the character to replace any member of replace_set
2244             with
2245
2246     Returns:
2247         The string with replacements executed.
2248
2249     >>> s = 'this_is a-test!'
2250     >>> replace_all(s, ' _-!', '')
2251     'thisisatest'
2252     """
2253     for char in replace_set:
2254         in_str = in_str.replace(char, replacement)
2255     return in_str
2256
2257
2258 def replace_nth(in_str: str, source: str, target: str, nth: int):
2259     """
2260     Replaces the nth occurrance of a substring within a string.
2261
2262     Args:
2263         in_str: the string in which to run the replacement
2264         source: the substring to replace
2265         target: the replacement text
2266         nth: which occurrance of source to replace?
2267
2268     >>> replace_nth('this is a test', ' ', '-', 3)
2269     'this is a-test'
2270     """
2271     where = [m.start() for m in re.finditer(source, in_str)][nth - 1]
2272     before = in_str[:where]
2273     after = in_str[where:]
2274     after = after.replace(source, target, 1)
2275     return before + after
2276
2277
2278 if __name__ == '__main__':
2279     import doctest
2280
2281     doctest.testmod()