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