4 from itertools import zip_longest
10 from typing import Any, List, Optional
12 from uuid import uuid4
14 import dateparse.dateparse_utils as dp
17 logger = logging.getLogger(__name__)
19 NUMBER_RE = re.compile(r"^([+\-]?)((\d+)(\.\d+)?([e|E]\d+)?|\.\d+)$")
21 HEX_NUMBER_RE = re.compile(r"^([+|-]?)0[x|X]([0-9A-Fa-f]+)$")
23 OCT_NUMBER_RE = re.compile(r"^([+|-]?)0[O|o]([0-7]+)$")
25 BIN_NUMBER_RE = re.compile(r"^([+|-]?)0[B|b]([0|1]+)$")
28 r"([a-z-]+://)" # scheme
29 r"([a-z_\d-]+:[a-z_\d-]+@)?" # user:password
31 r"((?<!\.)[a-z\d]+[a-z\d.-]+\.[a-z]{2,6}|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|localhost)" # domain
32 r"(:\d{2,})?" # port number
33 r"(/[a-z\d_%+-]*)*" # folders
34 r"(\.[a-z\d_%+-]+)*" # file extension
35 r"(\?[a-z\d_+%-=]*)?" # query string
39 URL_RE = re.compile(r"^{}$".format(URLS_RAW_STRING), re.IGNORECASE)
41 URLS_RE = re.compile(r"({})".format(URLS_RAW_STRING), re.IGNORECASE)
43 ESCAPED_AT_SIGN = re.compile(r'(?!"[^"]*)@+(?=[^"]*")|\\@')
45 EMAILS_RAW_STRING = r"[a-zA-Z\d._\+\-'`!%#$&*/=\?\^\{\}\|~\\]+@[a-z\d-]+\.?[a-z\d-]+\.[a-z]{2,4}"
47 EMAIL_RE = re.compile(r"^{}$".format(EMAILS_RAW_STRING))
49 EMAILS_RE = re.compile(r"({})".format(EMAILS_RAW_STRING))
51 CAMEL_CASE_TEST_RE = re.compile(
52 r"^[a-zA-Z]*([a-z]+[A-Z]+|[A-Z]+[a-z]+)[a-zA-Z\d]*$"
55 CAMEL_CASE_REPLACE_RE = re.compile(r"([a-z]|[A-Z]+)(?=[A-Z])")
57 SNAKE_CASE_TEST_RE = re.compile(
58 r"^([a-z]+\d*_[a-z\d_]*|_+[a-z\d]+[a-z\d_]*)$", re.IGNORECASE
61 SNAKE_CASE_TEST_DASH_RE = re.compile(
62 r"([a-z]+\d*-[a-z\d-]*|-+[a-z\d]+[a-z\d-]*)$", re.IGNORECASE
65 SNAKE_CASE_REPLACE_RE = re.compile(r"(_)([a-z\d])")
67 SNAKE_CASE_REPLACE_DASH_RE = re.compile(r"(-)([a-z\d])")
70 "VISA": re.compile(r"^4\d{12}(?:\d{3})?$"),
71 "MASTERCARD": re.compile(r"^5[1-5]\d{14}$"),
72 "AMERICAN_EXPRESS": re.compile(r"^3[47]\d{13}$"),
73 "DINERS_CLUB": re.compile(r"^3(?:0[0-5]|[68]\d)\d{11}$"),
74 "DISCOVER": re.compile(r"^6(?:011|5\d{2})\d{12}$"),
75 "JCB": re.compile(r"^(?:2131|1800|35\d{3})\d{11}$"),
78 JSON_WRAPPER_RE = re.compile(
79 r"^\s*[\[{]\s*(.*)\s*[\}\]]\s*$", re.MULTILINE | re.DOTALL
83 r"^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$", re.IGNORECASE
86 UUID_HEX_OK_RE = re.compile(
87 r"^[a-f\d]{8}-?[a-f\d]{4}-?[a-f\d]{4}-?[a-f\d]{4}-?[a-f\d]{12}$",
91 SHALLOW_IP_V4_RE = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
93 IP_V6_RE = re.compile(r"^([a-z\d]{0,4}:){7}[a-z\d]{0,4}$", re.IGNORECASE)
95 MAC_ADDRESS_RE = re.compile(
96 r"^([0-9A-F]{2}[:-]){5}([0-9A-F]{2})", re.IGNORECASE
99 WORDS_COUNT_RE = re.compile(
100 r"\W*[^\W_]+\W*", re.IGNORECASE | re.MULTILINE | re.UNICODE
103 HTML_RE = re.compile(
104 r"((<([a-z]+:)?[a-z]+[^>]*/?>)(.*?(</([a-z]+:)?[a-z]+>))?|<!--.*-->|<!doctype.*>)",
105 re.IGNORECASE | re.MULTILINE | re.DOTALL,
108 HTML_TAG_ONLY_RE = re.compile(
109 r"(<([a-z]+:)?[a-z]+[^>]*/?>|</([a-z]+:)?[a-z]+>|<!--.*-->|<!doctype.*>)",
110 re.IGNORECASE | re.MULTILINE | re.DOTALL,
113 SPACES_RE = re.compile(r"\s")
115 NO_LETTERS_OR_NUMBERS_RE = re.compile(
116 r"[^\w\d]+|_+", re.IGNORECASE | re.UNICODE
119 MARGIN_RE = re.compile(r"^[^\S\r\n]+")
121 ESCAPE_SEQUENCE_RE = re.compile(r"
\e\[[^A-Za-z]*[A-Za-z]")
137 def is_none_or_empty(in_str: Optional[str]) -> bool:
138 return in_str is None or len(in_str.strip()) == 0
141 def is_string(obj: Any) -> bool:
143 Checks if an object is a string.
145 return isinstance(obj, str)
148 def is_empty_string(in_str: Any) -> bool:
149 return is_string(in_str) and in_str.strip() == ""
152 def is_full_string(in_str: Any) -> bool:
153 return is_string(in_str) and in_str.strip() != ""
156 def is_number(in_str: str) -> bool:
158 Checks if a string is a valid number.
160 if not is_string(in_str):
161 raise ValueError(in_str)
162 return NUMBER_RE.match(in_str) is not None
165 def is_integer_number(in_str: str) -> bool:
167 Checks whether the given string represents an integer or not.
169 An integer may be signed or unsigned or use a "scientific notation".
173 >>> is_integer('42') # returns true
174 >>> is_integer('42.0') # returns false
177 (is_number(in_str) and "." not in in_str) or
178 is_hexidecimal_integer_number(in_str) or
179 is_octal_integer_number(in_str) or
180 is_binary_integer_number(in_str)
184 def is_hexidecimal_integer_number(in_str: str) -> bool:
185 if not is_string(in_str):
186 raise ValueError(in_str)
187 return HEX_NUMBER_RE.match(in_str) is not None
190 def is_octal_integer_number(in_str: str) -> bool:
191 if not is_string(in_str):
192 raise ValueError(in_str)
193 return OCT_NUMBER_RE.match(in_str) is not None
196 def is_binary_integer_number(in_str: str) -> bool:
197 if not is_string(in_str):
198 raise ValueError(in_str)
199 return BIN_NUMBER_RE.match(in_str) is not None
202 def to_int(in_str: str) -> int:
203 if not is_string(in_str):
204 raise ValueError(in_str)
205 if is_binary_integer_number(in_str):
206 return int(in_str, 2)
207 if is_octal_integer_number(in_str):
208 return int(in_str, 8)
209 if is_hexidecimal_integer_number(in_str):
210 return int(in_str, 16)
214 def is_decimal_number(in_str: str) -> bool:
216 Checks whether the given string represents a decimal or not.
218 A decimal may be signed or unsigned or use a "scientific notation".
220 >>> is_decimal('42.0') # returns true
221 >>> is_decimal('42') # returns false
223 return is_number(in_str) and "." in in_str
226 def strip_escape_sequences(in_str: str) -> str:
227 in_str = ESCAPE_SEQUENCE_RE.sub("", in_str)
231 def add_thousands_separator(in_str: str, *, separator_char = ',', places = 3) -> str:
232 if isinstance(in_str, int):
235 if is_number(in_str):
236 return _add_thousands_separator(
238 separator_char = separator_char,
241 raise ValueError(in_str)
244 def _add_thousands_separator(in_str: str, *, separator_char = ',', places = 3) -> str:
247 (in_str, decimal_part) = in_str.split('.')
248 tmp = [iter(in_str[::-1])] * places
249 ret = separator_char.join(
250 "".join(x) for x in zip_longest(*tmp, fillvalue=""))[::-1]
251 if len(decimal_part) > 0:
258 # scheme://username:
[email protected]:8042/folder/subfolder/file.extension?param=value¶m2=value2#hash
259 def is_url(in_str: Any, allowed_schemes: Optional[List[str]] = None) -> bool:
261 Check if a string is a valid url.
265 >>> is_url('http://www.mysite.com') # returns true
266 >>> is_url('https://mysite.com') # returns true
267 >>> is_url('.mysite.com') # returns false
269 if not is_full_string(in_str):
272 valid = URL_RE.match(in_str) is not None
275 return valid and any([in_str.startswith(s) for s in allowed_schemes])
279 def is_email(in_str: Any) -> bool:
281 Check if a string is a valid email.
283 Reference: https://tools.ietf.org/html/rfc3696#section-3
288 >>> is_email('@gmail.com') # returns false
291 not is_full_string(in_str)
293 or in_str.startswith(".")
298 # we expect 2 tokens, one before "@" and one after, otherwise
299 # we have an exception and the email is not valid.
300 head, tail = in_str.split("@")
302 # head's size must be <= 64, tail <= 255, head must not start
303 # with a dot or contain multiple consecutive dots.
307 or head.endswith(".")
312 # removes escaped spaces, so that later on the test regex will
314 head = head.replace("\\ ", "")
315 if head.startswith('"') and head.endswith('"'):
316 head = head.replace(" ", "")[1:-1]
317 return EMAIL_RE.match(head + "@" + tail) is not None
320 # borderline case in which we have multiple "@" signs but the
321 # head part is correctly escaped.
322 if ESCAPED_AT_SIGN.search(in_str) is not None:
323 # replace "@" with "a" in the head
324 return is_email(ESCAPED_AT_SIGN.sub("a", in_str))
328 def suffix_string_to_number(in_str: str) -> Optional[int]:
329 """Take a string like "33Gb" and convert it into a number (of bytes)
330 like 34603008. Return None if the input string is not valid.
333 def suffix_capitalize(s: str) -> str:
337 return f"{s[0].upper()}{s[1].lower()}"
338 return suffix_capitalize(s[0:1])
340 if is_string(in_str):
341 if is_integer_number(in_str):
342 return to_int(in_str)
343 suffixes = [in_str[-2:], in_str[-1:]]
344 rest = [in_str[:-2], in_str[:-1]]
345 for x in range(len(suffixes)):
347 s = suffix_capitalize(s)
348 multiplier = NUM_SUFFIXES.get(s, None)
349 if multiplier is not None:
351 if is_integer_number(r):
352 return int(r) * multiplier
356 def number_to_suffix_string(num: int) -> Optional[str]:
357 """Take a number (of bytes) and returns a string like "43.8Gb".
358 Returns none if the input is invalid.
362 for (sfx, size) in NUM_SUFFIXES.items():
367 if suffix is not None:
368 return f"{d:.1f}{suffix}"
373 def is_credit_card(in_str: Any, card_type: str = None) -> bool:
375 Checks if a string is a valid credit card number.
376 If card type is provided then it checks against that specific type only,
377 otherwise any known credit card number will be accepted.
379 Supported card types are the following:
388 if not is_full_string(in_str):
391 if card_type is not None:
392 if card_type not in CREDIT_CARDS:
394 f'Invalid card type "{card_type}". Valid types are: {CREDIT_CARDS.keys()}'
396 return CREDIT_CARDS[card_type].match(in_str) is not None
397 for c in CREDIT_CARDS:
398 if CREDIT_CARDS[c].match(in_str) is not None:
403 def is_camel_case(in_str: Any) -> bool:
405 Checks if a string is formatted as camel case.
407 A string is considered camel case when:
409 - it's composed only by letters ([a-zA-Z]) and optionally numbers ([0-9])
410 - it contains both lowercase and uppercase letters
411 - it does not start with a number
414 is_full_string(in_str) and CAMEL_CASE_TEST_RE.match(in_str) is not None
418 def is_snake_case(in_str: Any, *, separator: str = "_") -> bool:
420 Checks if a string is formatted as "snake case".
422 A string is considered snake case when:
424 - it's composed only by lowercase/uppercase letters and digits
425 - it contains at least one underscore (or provided separator)
426 - it does not start with a number
428 if is_full_string(in_str):
429 re_map = {"_": SNAKE_CASE_TEST_RE, "-": SNAKE_CASE_TEST_DASH_RE}
431 r"([a-z]+\d*{sign}[a-z\d{sign}]*|{sign}+[a-z\d]+[a-z\d{sign}]*)"
436 re_template.format(sign=re.escape(separator)), re.IGNORECASE
439 return r.match(in_str) is not None
443 def is_json(in_str: Any) -> bool:
445 Check if a string is a valid json.
449 >>> is_json('{"name": "Peter"}') # returns true
450 >>> is_json('[1, 2, 3]') # returns true
451 >>> is_json('{nope}') # returns false
453 if is_full_string(in_str) and JSON_WRAPPER_RE.match(in_str) is not None:
455 return isinstance(json.loads(in_str), (dict, list))
456 except (TypeError, ValueError, OverflowError):
461 def is_uuid(in_str: Any, allow_hex: bool = False) -> bool:
463 Check if a string is a valid UUID.
467 >>> is_uuid('6f8aa2f9-686c-4ac3-8766-5712354a04cf') # returns true
468 >>> is_uuid('6f8aa2f9686c4ac387665712354a04cf') # returns false
469 >>> is_uuid('6f8aa2f9686c4ac387665712354a04cf', allow_hex=True) # returns true
471 # string casting is used to allow UUID itself as input data type
474 return UUID_HEX_OK_RE.match(s) is not None
475 return UUID_RE.match(s) is not None
478 def is_ip_v4(in_str: Any) -> bool:
480 Checks if a string is a valid ip v4.
484 >>> is_ip_v4('255.200.100.75') # returns true
485 >>> is_ip_v4('nope') # returns false (not an ip)
486 >>> is_ip_v4('255.200.100.999') # returns false (999 is out of range)
488 if not is_full_string(in_str) or SHALLOW_IP_V4_RE.match(in_str) is None:
491 # checks that each entry in the ip is in the valid range (0 to 255)
492 for token in in_str.split("."):
493 if not 0 <= int(token) <= 255:
498 def extract_ip_v4(in_str: Any) -> Optional[str]:
500 Extracts the IPv4 chunk of a string or None.
502 if not is_full_string(in_str):
505 m = SHALLOW_IP_V4_RE.match(in_str)
511 def is_ip_v6(in_str: Any) -> bool:
513 Checks if a string is a valid ip v6.
517 >>> is_ip_v6('2001:db8:85a3:0000:0000:8a2e:370:7334') # returns true
518 >>> is_ip_v6('2001:db8:85a3:0000:0000:8a2e:370:?') # returns false (invalid "?")
520 return is_full_string(in_str) and IP_V6_RE.match(in_str) is not None
523 def extract_ip_v6(in_str: Any) -> Optional[str]:
525 Extract IPv6 chunk or None.
527 if not is_full_string(in_str):
530 m = IP_V6_RE.match(in_str)
536 def is_ip(in_str: Any) -> bool:
538 Checks if a string is a valid ip (either v4 or v6).
542 >>> is_ip('255.200.100.75') # returns true
543 >>> is_ip('2001:db8:85a3:0000:0000:8a2e:370:7334') # returns true
544 >>> is_ip('1.2.3') # returns false
546 return is_ip_v6(in_str) or is_ip_v4(in_str)
549 def extract_ip(in_str: Any) -> Optional[str]:
550 """Extract the IP address or None."""
551 ip = extract_ip_v4(in_str)
553 ip = extract_ip_v6(in_str)
557 def is_mac_address(in_str: Any) -> bool:
558 """Return True if in_str is a valid MAC address false otherwise."""
559 return is_full_string(in_str) and MAC_ADDRESS_RE.match(in_str) is not None
562 def extract_mac_address(in_str: Any, *, separator: str = ":") -> Optional[str]:
563 """Extract the MAC address from in_str"""
564 if not is_full_string(in_str):
567 m = MAC_ADDRESS_RE.match(in_str)
570 mac.replace(":", separator)
571 mac.replace("-", separator)
576 def is_slug(in_str: Any, separator: str = "-") -> bool:
578 Checks if a given string is a slug (as created by `slugify()`).
582 >>> is_slug('my-blog-post-title') # returns true
583 >>> is_slug('My blog post title') # returns false
585 :param in_str: String to check.
587 :param separator: Join sign used by the slug.
589 :return: True if slug, false otherwise.
591 if not is_full_string(in_str):
593 rex = r"^([a-z\d]+" + re.escape(separator) + r"*?)*[a-z\d]$"
594 return re.match(rex, in_str) is not None
597 def contains_html(in_str: str) -> bool:
599 Checks if the given string contains HTML/XML tags.
601 By design, this function matches ANY type of tag, so don't expect to use it
602 as an HTML validator, its goal is to detect "malicious" or undesired tags in the text.
606 >>> contains_html('my string is <strong>bold</strong>') # returns true
607 >>> contains_html('my string is not bold') # returns false
609 if not is_string(in_str):
610 raise ValueError(in_str)
611 return HTML_RE.search(in_str) is not None
614 def words_count(in_str: str) -> int:
616 Returns the number of words contained into the given string.
618 This method is smart, it does consider only sequence of one or more letter and/or numbers
619 as "words", so a string like this: "! @ # % ... []" will return zero!
620 Moreover it is aware of punctuation, so the count for a string like "one,two,three.stop"
621 will be 4 not 1 (even if there are no spaces in the string).
625 >>> words_count('hello world') # returns 2
626 >>> words_count('one,two,three.stop') # returns 4
628 if not is_string(in_str):
629 raise ValueError(in_str)
630 return len(WORDS_COUNT_RE.findall(in_str))
633 def generate_uuid(as_hex: bool = False) -> str:
635 Generated an UUID string (using `uuid.uuid4()`).
639 >>> uuid() # possible output: '97e3a716-6b33-4ab9-9bb1-8128cb24d76b'
640 >>> uuid(as_hex=True) # possible output: '97e3a7166b334ab99bb18128cb24d76b'
648 def generate_random_alphanumeric_string(size: int) -> str:
650 Returns a string of the specified size containing random
651 characters (uppercase/lowercase ascii letters and digits).
655 >>> random_string(9) # possible output: "cx3QQbzYg"
658 raise ValueError("size must be >= 1")
659 chars = string.ascii_letters + string.digits
660 buffer = [random.choice(chars) for _ in range(size)]
661 return from_char_list(buffer)
664 def reverse(in_str: str) -> str:
666 Returns the string with its chars reversed.
668 if not is_string(in_str):
669 raise ValueError(in_str)
673 def camel_case_to_snake_case(in_str, *, separator="_"):
675 Convert a camel case string into a snake case one.
676 (The original string is returned if is not a valid camel case string)
678 if not is_string(in_str):
679 raise ValueError(in_str)
680 if not is_camel_case(in_str):
682 return CAMEL_CASE_REPLACE_RE.sub(
683 lambda m: m.group(1) + separator, in_str
687 def snake_case_to_camel_case(
688 in_str: str, *, upper_case_first: bool = True, separator: str = "_"
691 Convert a snake case string into a camel case one.
692 (The original string is returned if is not a valid snake case string)
694 if not is_string(in_str):
695 raise ValueError(in_str)
696 if not is_snake_case(in_str, separator=separator):
698 tokens = [s.title() for s in in_str.split(separator) if is_full_string(s)]
699 if not upper_case_first:
700 tokens[0] = tokens[0].lower()
701 return from_char_list(tokens)
704 def to_char_list(in_str: str) -> List[str]:
705 if not is_string(in_str):
710 def from_char_list(in_list: List[str]) -> str:
711 return "".join(in_list)
714 def shuffle(in_str: str) -> str:
715 """Return a new string containing same chars of the given one but in
718 if not is_string(in_str):
719 raise ValueError(in_str)
721 # turn the string into a list of chars
722 chars = to_char_list(in_str)
723 random.shuffle(chars)
724 return from_char_list(chars)
727 def strip_html(in_str: str, keep_tag_content: bool = False) -> str:
729 Remove html code contained into the given string.
733 >>> strip_html('test: <a href="foo/bar">click here</a>') # returns 'test: '
734 >>> strip_html('test: <a href="foo/bar">click here</a>', keep_tag_content=True) # returns 'test: click here'
736 if not is_string(in_str):
737 raise ValueError(in_str)
738 r = HTML_TAG_ONLY_RE if keep_tag_content else HTML_RE
739 return r.sub("", in_str)
742 def asciify(in_str: str) -> str:
744 Force string content to be ascii-only by translating all non-ascii chars into the closest possible representation
745 (eg: ó -> o, Ë -> E, ç -> c...).
747 **Bear in mind**: Some chars may be lost if impossible to translate.
751 >>> asciify('èéùúòóäåëýñÅÀÁÇÌÍÑÓË') # returns 'eeuuooaaeynAAACIINOE'
753 if not is_string(in_str):
754 raise ValueError(in_str)
756 # "NFKD" is the algorithm which is able to successfully translate
757 # the most of non-ascii chars.
758 normalized = unicodedata.normalize("NFKD", in_str)
760 # encode string forcing ascii and ignore any errors
761 # (unrepresentable chars will be stripped out)
762 ascii_bytes = normalized.encode("ascii", "ignore")
764 # turns encoded bytes into an utf-8 string
765 return ascii_bytes.decode("utf-8")
768 def slugify(in_str: str, *, separator: str = "-") -> str:
770 Converts a string into a "slug" using provided separator.
771 The returned string has the following properties:
774 - all letters are in lower case
775 - all punctuation signs and non alphanumeric chars are removed
776 - words are divided using provided separator
777 - all chars are encoded as ascii (by using `asciify()`)
782 >>> slugify('Top 10 Reasons To Love Dogs!!!') # returns: 'top-10-reasons-to-love-dogs'
783 >>> slugify('Mönstér Mägnët') # returns 'monster-magnet'
785 if not is_string(in_str):
786 raise ValueError(in_str)
788 # replace any character that is NOT letter or number with spaces
789 out = NO_LETTERS_OR_NUMBERS_RE.sub(" ", in_str.lower()).strip()
791 # replace spaces with join sign
792 out = SPACES_RE.sub(separator, out)
794 # normalize joins (remove duplicates)
795 out = re.sub(re.escape(separator) + r"+", separator, out)
799 def to_bool(in_str: str) -> bool:
801 Turns a string into a boolean based on its content (CASE INSENSITIVE).
803 A positive boolean (True) is returned if the string value is one of the following:
810 Otherwise False is returned.
812 if not is_string(in_str):
813 raise ValueError(in_str)
814 return in_str.lower() in ("true", "1", "yes", "y", "t")
817 def to_date(in_str: str) -> Optional[datetime.date]:
822 except dp.ParseException:
823 logger.warning(f'Unable to parse date {in_str}.')
827 def valid_date(in_str: str) -> bool:
832 except dp.ParseException:
833 logger.warning(f'Unable to parse date {in_str}.')
837 def to_datetime(in_str: str) -> Optional[datetime.datetime]:
841 if type(dt) == datetime.datetime:
844 logger.warning(f'Unable to parse datetime {in_str}.')
848 def valid_datetime(in_str: str) -> bool:
849 _ = to_datetime(in_str)
852 logger.warning(f'Unable to parse datetime {in_str}.')
856 def dedent(in_str: str) -> str:
858 Removes tab indentation from multi line strings (inspired by analogous Scala function).
874 if not is_string(in_str):
875 raise ValueError(in_str)
876 line_separator = '\n'
877 lines = [MARGIN_RE.sub('', line) for line in in_str.split(line_separator)]
878 return line_separator.join(lines)
881 def indent(in_str: str, amount: int) -> str:
882 if not is_string(in_str):
883 raise ValueError(in_str)
884 line_separator = '\n'
885 lines = [" " * amount + line for line in in_str.split(line_separator)]
886 return line_separator.join(lines)
889 def sprintf(*args, **kwargs) -> str:
892 sep = kwargs.pop("sep", None)
894 if not isinstance(sep, str):
895 raise TypeError("sep must be None or a string")
897 end = kwargs.pop("end", None)
899 if not isinstance(end, str):
900 raise TypeError("end must be None or a string")
903 raise TypeError("invalid keyword arguments to sprint()")
909 for i, arg in enumerate(args):
912 if isinstance(arg, str):
920 def is_are(n: int) -> str:
926 def pluralize(n: int) -> str:
932 def thify(n: int) -> str:
934 assert is_integer_number(digit)