Moved more logic from tplink.py into a library.
[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     Returns true if the input string is either None or an empty string.
171
172     >>> is_none_or_empty("")
173     True
174     >>> is_none_or_empty(None)
175     True
176     >>> is_none_or_empty("   \t   ")
177     True
178     >>> is_none_or_empty('Test')
179     False
180     """
181     return in_str is None or len(in_str.strip()) == 0
182
183
184 def is_string(obj: Any) -> bool:
185     """
186     Checks if an object is a string.
187
188     >>> is_string('test')
189     True
190     >>> is_string(123)
191     False
192     >>> is_string(100.3)
193     False
194     >>> is_string([1, 2, 3])
195     False
196     """
197     return isinstance(obj, str)
198
199
200 def is_empty_string(in_str: Any) -> bool:
201     return is_empty(in_str)
202
203
204 def is_empty(in_str: Any) -> bool:
205     """
206     Checks if input is a string and empty or only whitespace.
207
208     >>> is_empty('')
209     True
210     >>> is_empty('    \t\t    ')
211     True
212     >>> is_empty('test')
213     False
214     >>> is_empty(100.88)
215     False
216     >>> is_empty([1, 2, 3])
217     False
218     """
219     return is_string(in_str) and in_str.strip() == ""
220
221
222 def is_full_string(in_str: Any) -> bool:
223     """
224     Checks that input is a string and is not empty ('') or only whitespace.
225
226     >>> is_full_string('test!')
227     True
228     >>> is_full_string('')
229     False
230     >>> is_full_string('      ')
231     False
232     >>> is_full_string(100.999)
233     False
234     >>> is_full_string({"a": 1, "b": 2})
235     False
236     """
237     return is_string(in_str) and in_str.strip() != ""
238
239
240 def is_number(in_str: str) -> bool:
241     """
242     Checks if a string is a valid number.
243
244     >>> is_number(100.5)
245     Traceback (most recent call last):
246     ...
247     ValueError: 100.5
248     >>> is_number("100.5")
249     True
250     >>> is_number("test")
251     False
252     >>> is_number("99")
253     True
254     >>> is_number([1, 2, 3])
255     Traceback (most recent call last):
256     ...
257     ValueError: [1, 2, 3]
258     """
259     if not is_string(in_str):
260         raise ValueError(in_str)
261     return NUMBER_RE.match(in_str) is not None
262
263
264 def is_integer_number(in_str: str) -> bool:
265     """
266     Checks whether the given string represents an integer or not.
267
268     An integer may be signed or unsigned or use a "scientific notation".
269
270     >>> is_integer_number('42')
271     True
272     >>> is_integer_number('42.0')
273     False
274     """
275     return (
276         (is_number(in_str) and "." not in in_str)
277         or is_hexidecimal_integer_number(in_str)
278         or is_octal_integer_number(in_str)
279         or is_binary_integer_number(in_str)
280     )
281
282
283 def is_hexidecimal_integer_number(in_str: str) -> bool:
284     """
285     Checks whether a string is a hex integer number.
286
287     >>> is_hexidecimal_integer_number('0x12345')
288     True
289     >>> is_hexidecimal_integer_number('0x1A3E')
290     True
291     >>> is_hexidecimal_integer_number('1234')  # Needs 0x
292     False
293     >>> is_hexidecimal_integer_number('-0xff')
294     True
295     >>> is_hexidecimal_integer_number('test')
296     False
297     >>> is_hexidecimal_integer_number(12345)  # Not a string
298     Traceback (most recent call last):
299     ...
300     ValueError: 12345
301     >>> is_hexidecimal_integer_number(101.4)
302     Traceback (most recent call last):
303     ...
304     ValueError: 101.4
305     >>> is_hexidecimal_integer_number(0x1A3E)
306     Traceback (most recent call last):
307     ...
308     ValueError: 6718
309     """
310     if not is_string(in_str):
311         raise ValueError(in_str)
312     return HEX_NUMBER_RE.match(in_str) is not None
313
314
315 def is_octal_integer_number(in_str: str) -> bool:
316     """
317     Checks whether a string is an octal number.
318
319     >>> is_octal_integer_number('0o777')
320     True
321     >>> is_octal_integer_number('-0O115')
322     True
323     >>> is_octal_integer_number('0xFF')  # Not octal, needs 0o
324     False
325     >>> is_octal_integer_number('7777')  # Needs 0o
326     False
327     >>> is_octal_integer_number('test')
328     False
329     """
330     if not is_string(in_str):
331         raise ValueError(in_str)
332     return OCT_NUMBER_RE.match(in_str) is not None
333
334
335 def is_binary_integer_number(in_str: str) -> bool:
336     """
337     Returns whether a string contains a binary number.
338
339     >>> is_binary_integer_number('0b10111')
340     True
341     >>> is_binary_integer_number('-0b111')
342     True
343     >>> is_binary_integer_number('0B10101')
344     True
345     >>> is_binary_integer_number('0b10102')
346     False
347     >>> is_binary_integer_number('0xFFF')
348     False
349     >>> is_binary_integer_number('test')
350     False
351     """
352     if not is_string(in_str):
353         raise ValueError(in_str)
354     return BIN_NUMBER_RE.match(in_str) is not None
355
356
357 def to_int(in_str: str) -> int:
358     """Returns the integral value of the string or raises on error.
359
360     >>> to_int('1234')
361     1234
362     >>> to_int('test')
363     Traceback (most recent call last):
364     ...
365     ValueError: invalid literal for int() with base 10: 'test'
366     """
367     if not is_string(in_str):
368         raise ValueError(in_str)
369     if is_binary_integer_number(in_str):
370         return int(in_str, 2)
371     if is_octal_integer_number(in_str):
372         return int(in_str, 8)
373     if is_hexidecimal_integer_number(in_str):
374         return int(in_str, 16)
375     return int(in_str)
376
377
378 def is_decimal_number(in_str: str) -> bool:
379     """
380     Checks whether the given string represents a decimal or not.
381
382     A decimal may be signed or unsigned or use a "scientific notation".
383
384     >>> is_decimal_number('42.0')
385     True
386     >>> is_decimal_number('42')
387     False
388     """
389     return is_number(in_str) and "." in in_str
390
391
392 def strip_escape_sequences(in_str: str) -> str:
393     """
394     Remove escape sequences in the input string.
395
396     >>> strip_escape_sequences('\e[12;11;22mthis is a test!')
397     'this is a test!'
398     """
399     in_str = ESCAPE_SEQUENCE_RE.sub("", in_str)
400     return in_str
401
402
403 def add_thousands_separator(in_str: str, *, separator_char=',', places=3) -> str:
404     """
405     Add thousands separator to a numeric string.  Also handles numbers.
406
407     >>> add_thousands_separator('12345678')
408     '12,345,678'
409     >>> add_thousands_separator(12345678)
410     '12,345,678'
411     >>> add_thousands_separator(12345678.99)
412     '12,345,678.99'
413     >>> add_thousands_separator('test')
414     Traceback (most recent call last):
415     ...
416     ValueError: test
417
418     """
419     if isinstance(in_str, numbers.Number):
420         in_str = f'{in_str}'
421     if is_number(in_str):
422         return _add_thousands_separator(in_str, separator_char=separator_char, places=places)
423     raise ValueError(in_str)
424
425
426 def _add_thousands_separator(in_str: str, *, separator_char=',', places=3) -> str:
427     decimal_part = ""
428     if '.' in in_str:
429         (in_str, decimal_part) = in_str.split('.')
430     tmp = [iter(in_str[::-1])] * places
431     ret = separator_char.join("".join(x) for x in zip_longest(*tmp, fillvalue=""))[::-1]
432     if len(decimal_part) > 0:
433         ret += '.'
434         ret += decimal_part
435     return ret
436
437
438 # Full url example:
439 # scheme://username:[email protected]:8042/folder/subfolder/file.extension?param=value&param2=value2#hash
440 def is_url(in_str: Any, allowed_schemes: Optional[List[str]] = None) -> bool:
441     """
442     Check if a string is a valid url.
443
444     >>> is_url('http://www.mysite.com')
445     True
446     >>> is_url('https://mysite.com')
447     True
448     >>> is_url('.mysite.com')
449     False
450     """
451     if not is_full_string(in_str):
452         return False
453
454     valid = URL_RE.match(in_str) is not None
455
456     if allowed_schemes:
457         return valid and any([in_str.startswith(s) for s in allowed_schemes])
458     return valid
459
460
461 def is_email(in_str: Any) -> bool:
462     """
463     Check if a string is a valid email.
464
465     Reference: https://tools.ietf.org/html/rfc3696#section-3
466
467     >>> is_email('[email protected]')
468     True
469     >>> is_email('@gmail.com')
470     False
471     """
472     if not is_full_string(in_str) or len(in_str) > 320 or in_str.startswith("."):
473         return False
474
475     try:
476         # we expect 2 tokens, one before "@" and one after, otherwise
477         # we have an exception and the email is not valid.
478         head, tail = in_str.split("@")
479
480         # head's size must be <= 64, tail <= 255, head must not start
481         # with a dot or contain multiple consecutive dots.
482         if len(head) > 64 or len(tail) > 255 or head.endswith(".") or (".." in head):
483             return False
484
485         # removes escaped spaces, so that later on the test regex will
486         # accept the string.
487         head = head.replace("\\ ", "")
488         if head.startswith('"') and head.endswith('"'):
489             head = head.replace(" ", "")[1:-1]
490         return EMAIL_RE.match(head + "@" + tail) is not None
491
492     except ValueError:
493         # borderline case in which we have multiple "@" signs but the
494         # head part is correctly escaped.
495         if ESCAPED_AT_SIGN.search(in_str) is not None:
496             # replace "@" with "a" in the head
497             return is_email(ESCAPED_AT_SIGN.sub("a", in_str))
498         return False
499
500
501 def suffix_string_to_number(in_str: str) -> Optional[int]:
502     """Take a string like "33Gb" and convert it into a number (of bytes)
503     like 34603008.  Return None if the input string is not valid.
504
505     >>> suffix_string_to_number('1Mb')
506     1048576
507     >>> suffix_string_to_number('13.1Gb')
508     14066017894
509     """
510
511     def suffix_capitalize(s: str) -> str:
512         if len(s) == 1:
513             return s.upper()
514         elif len(s) == 2:
515             return f"{s[0].upper()}{s[1].lower()}"
516         return suffix_capitalize(s[0:1])
517
518     if is_string(in_str):
519         if is_integer_number(in_str):
520             return to_int(in_str)
521         suffixes = [in_str[-2:], in_str[-1:]]
522         rest = [in_str[:-2], in_str[:-1]]
523         for x in range(len(suffixes)):
524             s = suffixes[x]
525             s = suffix_capitalize(s)
526             multiplier = NUM_SUFFIXES.get(s, None)
527             if multiplier is not None:
528                 r = rest[x]
529                 if is_integer_number(r):
530                     return to_int(r) * multiplier
531                 if is_decimal_number(r):
532                     return int(float(r) * multiplier)
533     return None
534
535
536 def number_to_suffix_string(num: int) -> Optional[str]:
537     """Take a number (of bytes) and returns a string like "43.8Gb".
538     Returns none if the input is invalid.
539
540     >>> number_to_suffix_string(14066017894)
541     '13.1Gb'
542     >>> number_to_suffix_string(1024 * 1024)
543     '1.0Mb'
544
545     """
546     d = 0.0
547     suffix = None
548     for (sfx, size) in NUM_SUFFIXES.items():
549         if num >= size:
550             d = num / size
551             suffix = sfx
552             break
553     if suffix is not None:
554         return f"{d:.1f}{suffix}"
555     else:
556         return f'{num:d}'
557
558
559 def is_credit_card(in_str: Any, card_type: str = None) -> bool:
560     """
561     Checks if a string is a valid credit card number.
562     If card type is provided then it checks against that specific type only,
563     otherwise any known credit card number will be accepted.
564
565     Supported card types are the following:
566
567     - VISA
568     - MASTERCARD
569     - AMERICAN_EXPRESS
570     - DINERS_CLUB
571     - DISCOVER
572     - JCB
573     """
574     if not is_full_string(in_str):
575         return False
576
577     if card_type is not None:
578         if card_type not in CREDIT_CARDS:
579             raise KeyError(
580                 f'Invalid card type "{card_type}". Valid types are: {CREDIT_CARDS.keys()}'
581             )
582         return CREDIT_CARDS[card_type].match(in_str) is not None
583     for c in CREDIT_CARDS:
584         if CREDIT_CARDS[c].match(in_str) is not None:
585             return True
586     return False
587
588
589 def is_camel_case(in_str: Any) -> bool:
590     """
591     Checks if a string is formatted as camel case.
592
593     A string is considered camel case when:
594
595     - it's composed only by letters ([a-zA-Z]) and optionally numbers ([0-9])
596     - it contains both lowercase and uppercase letters
597     - it does not start with a number
598     """
599     return is_full_string(in_str) and CAMEL_CASE_TEST_RE.match(in_str) is not None
600
601
602 def is_snake_case(in_str: Any, *, separator: str = "_") -> bool:
603     """
604     Checks if a string is formatted as "snake case".
605
606     A string is considered snake case when:
607
608     - it's composed only by lowercase/uppercase letters and digits
609     - it contains at least one underscore (or provided separator)
610     - it does not start with a number
611
612     >>> is_snake_case('this_is_a_test')
613     True
614     >>> is_snake_case('___This_Is_A_Test_1_2_3___')
615     True
616     >>> is_snake_case('this-is-a-test')
617     False
618     >>> is_snake_case('this-is-a-test', separator='-')
619     True
620
621     """
622     if is_full_string(in_str):
623         re_map = {"_": SNAKE_CASE_TEST_RE, "-": SNAKE_CASE_TEST_DASH_RE}
624         re_template = r"([a-z]+\d*{sign}[a-z\d{sign}]*|{sign}+[a-z\d]+[a-z\d{sign}]*)"
625         r = re_map.get(
626             separator,
627             re.compile(re_template.format(sign=re.escape(separator)), re.IGNORECASE),
628         )
629         return r.match(in_str) is not None
630     return False
631
632
633 def is_json(in_str: Any) -> bool:
634     """
635     Check if a string is a valid json.
636
637     >>> is_json('{"name": "Peter"}')
638     True
639     >>> is_json('[1, 2, 3]')
640     True
641     >>> is_json('{nope}')
642     False
643     """
644     if is_full_string(in_str) and JSON_WRAPPER_RE.match(in_str) is not None:
645         try:
646             return isinstance(json.loads(in_str), (dict, list))
647         except (TypeError, ValueError, OverflowError):
648             pass
649     return False
650
651
652 def is_uuid(in_str: Any, allow_hex: bool = False) -> bool:
653     """
654     Check if a string is a valid UUID.
655
656     >>> is_uuid('6f8aa2f9-686c-4ac3-8766-5712354a04cf')
657     True
658     >>> is_uuid('6f8aa2f9686c4ac387665712354a04cf')
659     False
660     >>> is_uuid('6f8aa2f9686c4ac387665712354a04cf', allow_hex=True)
661     True
662     """
663     # string casting is used to allow UUID itself as input data type
664     s = str(in_str)
665     if allow_hex:
666         return UUID_HEX_OK_RE.match(s) is not None
667     return UUID_RE.match(s) is not None
668
669
670 def is_ip_v4(in_str: Any) -> bool:
671     """
672     Checks if a string is a valid ip v4.
673
674     >>> is_ip_v4('255.200.100.75')
675     True
676     >>> is_ip_v4('nope')
677     False
678     >>> is_ip_v4('255.200.100.999')  # 999 out of range
679     False
680     """
681     if not is_full_string(in_str) or SHALLOW_IP_V4_RE.match(in_str) is None:
682         return False
683
684     # checks that each entry in the ip is in the valid range (0 to 255)
685     for token in in_str.split("."):
686         if not 0 <= int(token) <= 255:
687             return False
688     return True
689
690
691 def extract_ip_v4(in_str: Any) -> Optional[str]:
692     """
693     Extracts the IPv4 chunk of a string or None.
694
695     >>> extract_ip_v4('   The secret IP address: 127.0.0.1 (use it wisely)   ')
696     '127.0.0.1'
697     >>> extract_ip_v4('Your mom dresses you funny.')
698     """
699     if not is_full_string(in_str):
700         return None
701     m = ANYWHERE_IP_V4_RE.search(in_str)
702     if m is not None:
703         return m.group(0)
704     return None
705
706
707 def is_ip_v6(in_str: Any) -> bool:
708     """
709     Checks if a string is a valid ip v6.
710
711     >>> is_ip_v6('2001:db8:85a3:0000:0000:8a2e:370:7334')
712     True
713     >>> is_ip_v6('2001:db8:85a3:0000:0000:8a2e:370:?')    # invalid "?"
714     False
715     """
716     return is_full_string(in_str) and IP_V6_RE.match(in_str) is not None
717
718
719 def extract_ip_v6(in_str: Any) -> Optional[str]:
720     """
721     Extract IPv6 chunk or None.
722
723     >>> extract_ip_v6('IP: 2001:db8:85a3:0000:0000:8a2e:370:7334')
724     '2001:db8:85a3:0000:0000:8a2e:370:7334'
725     >>> extract_ip_v6("(and she's ugly too, btw)")
726     """
727     if not is_full_string(in_str):
728         return None
729     m = ANYWHERE_IP_V6_RE.search(in_str)
730     if m is not None:
731         return m.group(0)
732     return None
733
734
735 def is_ip(in_str: Any) -> bool:
736     """
737     Checks if a string is a valid ip (either v4 or v6).
738
739     >>> is_ip('255.200.100.75')
740     True
741     >>> is_ip('2001:db8:85a3:0000:0000:8a2e:370:7334')
742     True
743     >>> is_ip('1.2.3')
744     False
745     >>> is_ip('1.2.3.999')
746     False
747     """
748     return is_ip_v6(in_str) or is_ip_v4(in_str)
749
750
751 def extract_ip(in_str: Any) -> Optional[str]:
752     """
753     Extract the IP address or None.
754
755     >>> extract_ip('Attacker: 255.200.100.75')
756     '255.200.100.75'
757     >>> extract_ip('Remote host: 2001:db8:85a3:0000:0000:8a2e:370:7334')
758     '2001:db8:85a3:0000:0000:8a2e:370:7334'
759     >>> extract_ip('1.2.3')
760
761     """
762     ip = extract_ip_v4(in_str)
763     if ip is None:
764         ip = extract_ip_v6(in_str)
765     return ip
766
767
768 def is_mac_address(in_str: Any) -> bool:
769     """Return True if in_str is a valid MAC address false otherwise.
770
771     >>> is_mac_address("34:29:8F:12:0D:2F")
772     True
773     >>> is_mac_address('34:29:8f:12:0d:2f')
774     True
775     >>> is_mac_address('34-29-8F-12-0D-2F')
776     True
777     >>> is_mac_address("test")
778     False
779     """
780     return is_full_string(in_str) and MAC_ADDRESS_RE.match(in_str) is not None
781
782
783 def extract_mac_address(in_str: Any, *, separator: str = ":") -> Optional[str]:
784     """
785     Extract the MAC address from in_str.
786
787     >>> extract_mac_address(' MAC Address: 34:29:8F:12:0D:2F')
788     '34:29:8F:12:0D:2F'
789
790     >>> extract_mac_address('? (10.0.0.30) at d8:5d:e2:34:54:86 on em0 expires in 1176 seconds [ethernet]')
791     'd8:5d:e2:34:54:86'
792
793     """
794     if not is_full_string(in_str):
795         return None
796     in_str.strip()
797     m = ANYWHERE_MAC_ADDRESS_RE.search(in_str)
798     if m is not None:
799         mac = m.group(0)
800         mac.replace(":", separator)
801         mac.replace("-", separator)
802         return mac
803     return None
804
805
806 def is_slug(in_str: Any, separator: str = "-") -> bool:
807     """
808     Checks if a given string is a slug (as created by `slugify()`).
809
810     >>> is_slug('my-blog-post-title')
811     True
812     >>> is_slug('My blog post title')
813     False
814
815     """
816     if not is_full_string(in_str):
817         return False
818     rex = r"^([a-z\d]+" + re.escape(separator) + r"*?)*[a-z\d]$"
819     return re.match(rex, in_str) is not None
820
821
822 def contains_html(in_str: str) -> bool:
823     """
824     Checks if the given string contains HTML/XML tags.
825
826     By design, this function matches ANY type of tag, so don't expect to use it
827     as an HTML validator, its goal is to detect "malicious" or undesired tags in the text.
828
829     >>> contains_html('my string is <strong>bold</strong>')
830     True
831     >>> contains_html('my string is not bold')
832     False
833
834     """
835     if not is_string(in_str):
836         raise ValueError(in_str)
837     return HTML_RE.search(in_str) is not None
838
839
840 def words_count(in_str: str) -> int:
841     """
842     Returns the number of words contained into the given string.
843
844     This method is smart, it does consider only sequence of one or more letter and/or numbers
845     as "words", so a string like this: "! @ # % ... []" will return zero!
846     Moreover it is aware of punctuation, so the count for a string like "one,two,three.stop"
847     will be 4 not 1 (even if there are no spaces in the string).
848
849     >>> words_count('hello world')
850     2
851     >>> words_count('one,two,three.stop')
852     4
853
854     """
855     if not is_string(in_str):
856         raise ValueError(in_str)
857     return len(WORDS_COUNT_RE.findall(in_str))
858
859
860 def generate_uuid(omit_dashes: bool = False) -> str:
861     """
862     Generated an UUID string (using `uuid.uuid4()`).
863
864     generate_uuid() # possible output: '97e3a716-6b33-4ab9-9bb1-8128cb24d76b'
865     generate_uuid(omit_dashes=True) # possible output: '97e3a7166b334ab99bb18128cb24d76b'
866
867     """
868     uid = uuid4()
869     if omit_dashes:
870         return uid.hex
871     return str(uid)
872
873
874 def generate_random_alphanumeric_string(size: int) -> str:
875     """
876     Returns a string of the specified size containing random
877     characters (uppercase/lowercase ascii letters and digits).
878
879     random_string(9) # possible output: "cx3QQbzYg"
880
881     """
882     if size < 1:
883         raise ValueError("size must be >= 1")
884     chars = string.ascii_letters + string.digits
885     buffer = [random.choice(chars) for _ in range(size)]
886     return from_char_list(buffer)
887
888
889 def reverse(in_str: str) -> str:
890     """
891     Returns the string with its chars reversed.
892
893     >>> reverse('test')
894     'tset'
895
896     """
897     if not is_string(in_str):
898         raise ValueError(in_str)
899     return in_str[::-1]
900
901
902 def camel_case_to_snake_case(in_str, *, separator="_"):
903     """
904     Convert a camel case string into a snake case one.
905     (The original string is returned if is not a valid camel case string)
906
907     >>> camel_case_to_snake_case('MacAddressExtractorFactory')
908     'mac_address_extractor_factory'
909     >>> camel_case_to_snake_case('Luke Skywalker')
910     'Luke Skywalker'
911     """
912     if not is_string(in_str):
913         raise ValueError(in_str)
914     if not is_camel_case(in_str):
915         return in_str
916     return CAMEL_CASE_REPLACE_RE.sub(lambda m: m.group(1) + separator, in_str).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
1063     >>> to_bool('1')
1064     True
1065
1066     >>> to_bool('yes')
1067     True
1068
1069     >>> to_bool('no')
1070     False
1071
1072     >>> to_bool('huh?')
1073     False
1074
1075     >>> to_bool('on')
1076     True
1077
1078     """
1079     if not is_string(in_str):
1080         raise ValueError(in_str)
1081     return in_str.lower() in ("true", "1", "yes", "y", "t", "on")
1082
1083
1084 def to_date(in_str: str) -> Optional[datetime.date]:
1085     """
1086     Parses a date string.  See DateParser docs for details.
1087     """
1088     import dateparse.dateparse_utils as du
1089
1090     try:
1091         d = du.DateParser()  # type: ignore
1092         d.parse(in_str)
1093         return d.get_date()
1094     except du.ParseException:  # type: ignore
1095         msg = f'Unable to parse date {in_str}.'
1096         logger.warning(msg)
1097     return None
1098
1099
1100 def valid_date(in_str: str) -> bool:
1101     """
1102     True if the string represents a valid date.
1103     """
1104     import dateparse.dateparse_utils as dp
1105
1106     try:
1107         d = dp.DateParser()  # type: ignore
1108         _ = d.parse(in_str)
1109         return True
1110     except dp.ParseException:  # type: ignore
1111         msg = f'Unable to parse date {in_str}.'
1112         logger.warning(msg)
1113     return False
1114
1115
1116 def to_datetime(in_str: str) -> Optional[datetime.datetime]:
1117     """
1118     Parses a datetime string.  See DateParser docs for more info.
1119     """
1120     import dateparse.dateparse_utils as dp
1121
1122     try:
1123         d = dp.DateParser()  # type: ignore
1124         dt = d.parse(in_str)
1125         if isinstance(dt, datetime.datetime):
1126             return dt
1127     except ValueError:
1128         msg = f'Unable to parse datetime {in_str}.'
1129         logger.warning(msg)
1130     return None
1131
1132
1133 def valid_datetime(in_str: str) -> bool:
1134     """
1135     True if the string represents a valid datetime.
1136     """
1137     _ = to_datetime(in_str)
1138     if _ is not None:
1139         return True
1140     msg = f'Unable to parse datetime {in_str}.'
1141     logger.warning(msg)
1142     return False
1143
1144
1145 def squeeze(in_str: str, character_to_squeeze: str = ' ') -> str:
1146     """
1147     Squeeze runs of more than one character_to_squeeze into one.
1148
1149     >>> squeeze(' this        is       a    test    ')
1150     ' this is a test '
1151
1152     >>> squeeze('one|!||!|two|!||!|three', character_to_squeeze='|!|')
1153     'one|!|two|!|three'
1154
1155     """
1156     return re.sub(
1157         r'(' + re.escape(character_to_squeeze) + r')+',
1158         character_to_squeeze,
1159         in_str,
1160     )
1161
1162
1163 def dedent(in_str: str) -> str:
1164     """
1165     Removes tab indentation from multi line strings (inspired by analogous Scala function).
1166     """
1167     if not is_string(in_str):
1168         raise ValueError(in_str)
1169     line_separator = '\n'
1170     lines = [MARGIN_RE.sub('', line) for line in in_str.split(line_separator)]
1171     return line_separator.join(lines)
1172
1173
1174 def indent(in_str: str, amount: int) -> str:
1175     """
1176     Indents string by prepending amount spaces.
1177
1178     >>> indent('This is a test', 4)
1179     '    This is a test'
1180
1181     """
1182     if not is_string(in_str):
1183         raise ValueError(in_str)
1184     line_separator = '\n'
1185     lines = [" " * amount + line for line in in_str.split(line_separator)]
1186     return line_separator.join(lines)
1187
1188
1189 def sprintf(*args, **kwargs) -> str:
1190     """String printf, like in C"""
1191     ret = ""
1192
1193     sep = kwargs.pop("sep", None)
1194     if sep is not None:
1195         if not isinstance(sep, str):
1196             raise TypeError("sep must be None or a string")
1197
1198     end = kwargs.pop("end", None)
1199     if end is not None:
1200         if not isinstance(end, str):
1201             raise TypeError("end must be None or a string")
1202
1203     if kwargs:
1204         raise TypeError("invalid keyword arguments to sprint()")
1205
1206     if sep is None:
1207         sep = " "
1208     if end is None:
1209         end = "\n"
1210     for i, arg in enumerate(args):
1211         if i:
1212             ret += sep
1213         if isinstance(arg, str):
1214             ret += arg
1215         else:
1216             ret += str(arg)
1217     ret += end
1218     return ret
1219
1220
1221 def strip_ansi_sequences(in_str: str) -> str:
1222     """Strips ANSI sequences out of strings.
1223
1224     >>> import ansi as a
1225     >>> s = a.fg('blue') + 'blue!' + a.reset()
1226     >>> len(s)   # '\x1b[38;5;21mblue!\x1b[m'
1227     18
1228     >>> len(strip_ansi_sequences(s))
1229     5
1230     >>> strip_ansi_sequences(s)
1231     'blue!'
1232
1233     """
1234     return re.sub(r'\x1b\[[\d+;]*[a-z]', '', in_str)
1235
1236
1237 class SprintfStdout(contextlib.AbstractContextManager):
1238     """
1239     A context manager that captures outputs to stdout.
1240
1241     with SprintfStdout() as buf:
1242         print("test")
1243     print(buf())
1244
1245     'test\n'
1246     """
1247
1248     def __init__(self) -> None:
1249         self.destination = io.StringIO()
1250         self.recorder: contextlib.redirect_stdout
1251
1252     def __enter__(self) -> Callable[[], str]:
1253         self.recorder = contextlib.redirect_stdout(self.destination)
1254         self.recorder.__enter__()
1255         return lambda: self.destination.getvalue()
1256
1257     def __exit__(self, *args) -> Literal[False]:
1258         self.recorder.__exit__(*args)
1259         self.destination.seek(0)
1260         return False
1261
1262
1263 def capitalize_first_letter(txt: str) -> str:
1264     """Capitalize the first letter of a string.
1265
1266     >>> capitalize_first_letter('test')
1267     'Test'
1268     >>> capitalize_first_letter("ALREADY!")
1269     'ALREADY!'
1270
1271     """
1272     return txt[0].upper() + txt[1:]
1273
1274
1275 def it_they(n: int) -> str:
1276     """It or they?
1277
1278     >>> it_they(1)
1279     'it'
1280     >>> it_they(100)
1281     'they'
1282
1283     """
1284     if n == 1:
1285         return "it"
1286     return "they"
1287
1288
1289 def is_are(n: int) -> str:
1290     """Is or are?
1291
1292     >>> is_are(1)
1293     'is'
1294     >>> is_are(2)
1295     'are'
1296
1297     """
1298     if n == 1:
1299         return "is"
1300     return "are"
1301
1302
1303 def pluralize(n: int) -> str:
1304     """Add an s?
1305
1306     >>> pluralize(15)
1307     's'
1308     >>> count = 1
1309     >>> print(f'There {is_are(count)} {count} file{pluralize(count)}.')
1310     There is 1 file.
1311     >>> count = 4
1312     >>> print(f'There {is_are(count)} {count} file{pluralize(count)}.')
1313     There are 4 files.
1314
1315     """
1316     if n == 1:
1317         return ""
1318     return "s"
1319
1320
1321 def make_contractions(txt: str) -> str:
1322     """Glue words together to form contractions.
1323
1324     >>> make_contractions('It is nice today.')
1325     "It's nice today."
1326
1327     >>> make_contractions('I can    not even...')
1328     "I can't even..."
1329
1330     >>> make_contractions('She could not see!')
1331     "She couldn't see!"
1332
1333     >>> make_contractions('But she will not go.')
1334     "But she won't go."
1335
1336     >>> make_contractions('Verily, I shall not.')
1337     "Verily, I shan't."
1338
1339     >>> make_contractions('No you cannot.')
1340     "No you can't."
1341
1342     >>> make_contractions('I said you can not go.')
1343     "I said you can't go."
1344
1345     """
1346
1347     first_second = [
1348         (
1349             [
1350                 'are',
1351                 'could',
1352                 'did',
1353                 'has',
1354                 'have',
1355                 'is',
1356                 'must',
1357                 'should',
1358                 'was',
1359                 'were',
1360                 'would',
1361             ],
1362             ['(n)o(t)'],
1363         ),
1364         (
1365             [
1366                 "I",
1367                 "you",
1368                 "he",
1369                 "she",
1370                 "it",
1371                 "we",
1372                 "they",
1373                 "how",
1374                 "why",
1375                 "when",
1376                 "where",
1377                 "who",
1378                 "there",
1379             ],
1380             ['woul(d)', 'i(s)', 'a(re)', 'ha(s)', 'ha(ve)', 'ha(d)', 'wi(ll)'],
1381         ),
1382     ]
1383
1384     # Special cases: can't, shan't and won't.
1385     txt = re.sub(r'\b(can)\s*no(t)\b', r"\1'\2", txt, count=0, flags=re.IGNORECASE)
1386     txt = re.sub(r'\b(sha)ll\s*(n)o(t)\b', r"\1\2'\3", txt, count=0, flags=re.IGNORECASE)
1387     txt = re.sub(
1388         r'\b(w)ill\s*(n)(o)(t)\b',
1389         r"\1\3\2'\4",
1390         txt,
1391         count=0,
1392         flags=re.IGNORECASE,
1393     )
1394
1395     for first_list, second_list in first_second:
1396         for first in first_list:
1397             for second in second_list:
1398                 # Disallow there're/where're.  They're valid English
1399                 # but sound weird.
1400                 if (first in ('there', 'where')) and second == 'a(re)':
1401                     continue
1402
1403                 pattern = fr'\b({first})\s+{second}\b'
1404                 if second == '(n)o(t)':
1405                     replacement = r"\1\2'\3"
1406                 else:
1407                     replacement = r"\1'\2"
1408                 txt = re.sub(pattern, replacement, txt, count=0, flags=re.IGNORECASE)
1409
1410     return txt
1411
1412
1413 def thify(n: int) -> str:
1414     """Return the proper cardinal suffix for a number.
1415
1416     >>> thify(1)
1417     'st'
1418     >>> thify(33)
1419     'rd'
1420     >>> thify(16)
1421     'th'
1422
1423     """
1424     digit = str(n)
1425     assert is_integer_number(digit)
1426     digit = digit[-1:]
1427     if digit == "1":
1428         return "st"
1429     elif digit == "2":
1430         return "nd"
1431     elif digit == "3":
1432         return "rd"
1433     else:
1434         return "th"
1435
1436
1437 def ngrams(txt: str, n: int):
1438     """Return the ngrams from a string.
1439
1440     >>> [x for x in ngrams('This is a test', 2)]
1441     ['This is', 'is a', 'a test']
1442
1443     """
1444     words = txt.split()
1445     for ngram in ngrams_presplit(words, n):
1446         ret = ''
1447         for word in ngram:
1448             ret += f'{word} '
1449         yield ret.strip()
1450
1451
1452 def ngrams_presplit(words: Sequence[str], n: int):
1453     return list_utils.ngrams(words, n)
1454
1455
1456 def bigrams(txt: str):
1457     return ngrams(txt, 2)
1458
1459
1460 def trigrams(txt: str):
1461     return ngrams(txt, 3)
1462
1463
1464 def shuffle_columns_into_list(
1465     input_lines: Sequence[str], column_specs: Iterable[Iterable[int]], delim=''
1466 ) -> Iterable[str]:
1467     """Helper to shuffle / parse columnar data and return the results as a
1468     list.  The column_specs argument is an iterable collection of
1469     numeric sequences that indicate one or more column numbers to
1470     copy.
1471
1472     >>> cols = '-rwxr-xr-x 1 scott wheel 3.1K Jul  9 11:34 acl_test.py'.split()
1473     >>> shuffle_columns_into_list(
1474     ...     cols,
1475     ...     [ [8], [2, 3], [5, 6, 7] ],
1476     ...     delim=' ',
1477     ... )
1478     ['acl_test.py', 'scott wheel', 'Jul 9 11:34']
1479
1480     """
1481     out = []
1482
1483     # Column specs map input lines' columns into outputs.
1484     # [col1, col2...]
1485     for spec in column_specs:
1486         hunk = ''
1487         for n in spec:
1488             hunk = hunk + delim + input_lines[n]
1489         hunk = hunk.strip(delim)
1490         out.append(hunk)
1491     return out
1492
1493
1494 def shuffle_columns_into_dict(
1495     input_lines: Sequence[str],
1496     column_specs: Iterable[Tuple[str, Iterable[int]]],
1497     delim='',
1498 ) -> Dict[str, str]:
1499     """Helper to shuffle / parse columnar data and return the results
1500     as a dict.
1501
1502     >>> cols = '-rwxr-xr-x 1 scott wheel 3.1K Jul  9 11:34 acl_test.py'.split()
1503     >>> shuffle_columns_into_dict(
1504     ...     cols,
1505     ...     [ ('filename', [8]), ('owner', [2, 3]), ('mtime', [5, 6, 7]) ],
1506     ...     delim=' ',
1507     ... )
1508     {'filename': 'acl_test.py', 'owner': 'scott wheel', 'mtime': 'Jul 9 11:34'}
1509
1510     """
1511     out = {}
1512
1513     # Column specs map input lines' columns into outputs.
1514     # "key", [col1, col2...]
1515     for spec in column_specs:
1516         hunk = ''
1517         for n in spec[1]:
1518             hunk = hunk + delim + input_lines[n]
1519         hunk = hunk.strip(delim)
1520         out[spec[0]] = hunk
1521     return out
1522
1523
1524 def interpolate_using_dict(txt: str, values: Dict[str, str]) -> str:
1525     """Interpolate a string with data from a dict.
1526
1527     >>> interpolate_using_dict('This is a {adjective} {noun}.',
1528     ...                        {'adjective': 'good', 'noun': 'example'})
1529     'This is a good example.'
1530
1531     """
1532     return sprintf(txt.format(**values), end='')
1533
1534
1535 def to_ascii(x: str):
1536     """Encode as ascii bytes string.
1537
1538     >>> to_ascii('test')
1539     b'test'
1540
1541     >>> to_ascii(b'1, 2, 3')
1542     b'1, 2, 3'
1543
1544     """
1545     if isinstance(x, str):
1546         return x.encode('ascii')
1547     if isinstance(x, bytes):
1548         return x
1549     raise Exception('to_ascii works with strings and bytes')
1550
1551
1552 def to_base64(txt: str, *, encoding='utf-8', errors='surrogatepass') -> bytes:
1553     """Encode txt and then encode the bytes with a 64-character
1554     alphabet.  This is compatible with uudecode.
1555
1556     >>> to_base64('hello?')
1557     b'aGVsbG8/\\n'
1558
1559     """
1560     return base64.encodebytes(txt.encode(encoding, errors))
1561
1562
1563 def is_base64(txt: str) -> bool:
1564     """Determine whether a string is base64 encoded (with Python's standard
1565     base64 alphabet which is the same as what uuencode uses).
1566
1567     >>> is_base64('test')    # all letters in the b64 alphabet
1568     True
1569
1570     >>> is_base64('another test, how do you like this one?')
1571     False
1572
1573     >>> is_base64(b'aGVsbG8/\\n')    # Ending newline is ok.
1574     True
1575
1576     """
1577     a = string.ascii_uppercase + string.ascii_lowercase + string.digits + '+/'
1578     alphabet = set(a.encode('ascii'))
1579     for char in to_ascii(txt.strip()):
1580         if char not in alphabet:
1581             return False
1582     return True
1583
1584
1585 def from_base64(b64: bytes, encoding='utf-8', errors='surrogatepass') -> str:
1586     """Convert base64 encoded string back to normal strings.
1587
1588     >>> from_base64(b'aGVsbG8/\\n')
1589     'hello?'
1590
1591     """
1592     return base64.decodebytes(b64).decode(encoding, errors)
1593
1594
1595 def chunk(txt: str, chunk_size):
1596     """Chunk up a string.
1597
1598     >>> ' '.join(chunk('010011011100010110101010101010101001111110101000', 8))
1599     '01001101 11000101 10101010 10101010 10011111 10101000'
1600
1601     """
1602     if len(txt) % chunk_size != 0:
1603         msg = f'String to chunk\'s length ({len(txt)} is not an even multiple of chunk_size ({chunk_size})'
1604         logger.warning(msg)
1605         warnings.warn(msg, stacklevel=2)
1606     for x in range(0, len(txt), chunk_size):
1607         yield txt[x : x + chunk_size]
1608
1609
1610 def to_bitstring(txt: str, *, delimiter='', encoding='utf-8', errors='surrogatepass') -> str:
1611     """Encode txt and then chop it into bytes.  Note: only bitstrings
1612     with delimiter='' are interpretable by from_bitstring.
1613
1614     >>> to_bitstring('hello?')
1615     '011010000110010101101100011011000110111100111111'
1616
1617     >>> to_bitstring('test', delimiter=' ')
1618     '01110100 01100101 01110011 01110100'
1619
1620     >>> to_bitstring(b'test')
1621     '01110100011001010111001101110100'
1622
1623     """
1624     etxt = to_ascii(txt)
1625     bits = bin(int.from_bytes(etxt, 'big'))
1626     bits = bits[2:]
1627     return delimiter.join(chunk(bits.zfill(8 * ((len(bits) + 7) // 8)), 8))
1628
1629
1630 def is_bitstring(txt: str) -> bool:
1631     """Is this a bitstring?
1632
1633     >>> is_bitstring('011010000110010101101100011011000110111100111111')
1634     True
1635
1636     >>> is_bitstring('1234')
1637     False
1638
1639     """
1640     return is_binary_integer_number(f'0b{txt}')
1641
1642
1643 def from_bitstring(bits: str, encoding='utf-8', errors='surrogatepass') -> str:
1644     """Convert from bitstring back to bytes then decode into a str.
1645
1646     >>> from_bitstring('011010000110010101101100011011000110111100111111')
1647     'hello?'
1648
1649     """
1650     n = int(bits, 2)
1651     return n.to_bytes((n.bit_length() + 7) // 8, 'big').decode(encoding, errors) or '\0'
1652
1653
1654 def ip_v4_sort_key(txt: str) -> Optional[Tuple[int, ...]]:
1655     """Turn an IPv4 address into a tuple for sorting purposes.
1656
1657     >>> ip_v4_sort_key('10.0.0.18')
1658     (10, 0, 0, 18)
1659
1660     >>> ips = ['10.0.0.10', '100.0.0.1', '1.2.3.4', '10.0.0.9']
1661     >>> sorted(ips, key=lambda x: ip_v4_sort_key(x))
1662     ['1.2.3.4', '10.0.0.9', '10.0.0.10', '100.0.0.1']
1663
1664     """
1665     if not is_ip_v4(txt):
1666         print(f"not IP: {txt}")
1667         return None
1668     return tuple(int(x) for x in txt.split('.'))
1669
1670
1671 def path_ancestors_before_descendants_sort_key(volume: str) -> Tuple[str, ...]:
1672     """Chunk up a file path so that parent/ancestor paths sort before
1673     children/descendant paths.
1674
1675     >>> path_ancestors_before_descendants_sort_key('/usr/local/bin')
1676     ('usr', 'local', 'bin')
1677
1678     >>> paths = ['/usr/local', '/usr/local/bin', '/usr']
1679     >>> sorted(paths, key=lambda x: path_ancestors_before_descendants_sort_key(x))
1680     ['/usr', '/usr/local', '/usr/local/bin']
1681
1682     """
1683     return tuple(x for x in volume.split('/') if len(x) > 0)
1684
1685
1686 def replace_all(in_str: str, replace_set: str, replacement: str) -> str:
1687     """Execute several replace operations in a row.
1688
1689     >>> s = 'this_is a-test!'
1690     >>> replace_all(s, ' _-!', '')
1691     'thisisatest'
1692
1693     """
1694     for char in replace_set:
1695         in_str = in_str.replace(char, replacement)
1696     return in_str
1697
1698
1699 def replace_nth(in_str: str, source: str, target: str, nth: int):
1700     """Replaces the nth occurrance of a substring within a string.
1701
1702     >>> replace_nth('this is a test', ' ', '-', 3)
1703     'this is a-test'
1704
1705     """
1706     where = [m.start() for m in re.finditer(source, in_str)][nth - 1]
1707     before = in_str[:where]
1708     after = in_str[where:]
1709     after = after.replace(source, target, 1)
1710     return before + after
1711
1712
1713 if __name__ == '__main__':
1714     import doctest
1715
1716     doctest.testmod()