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