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