4 from itertools import zip_longest
10 from typing import Any, List, Optional
12 from uuid import uuid4
14 logger = logging.getLogger(__name__)
16 NUMBER_RE = re.compile(r"^([+\-]?)((\d+)(\.\d+)?([e|E]\d+)?|\.\d+)$")
18 HEX_NUMBER_RE = re.compile(r"^([+|-]?)0[x|X]([0-9A-Fa-f]+)$")
20 OCT_NUMBER_RE = re.compile(r"^([+|-]?)0[O|o]([0-7]+)$")
22 BIN_NUMBER_RE = re.compile(r"^([+|-]?)0[B|b]([0|1]+)$")
25 r"([a-z-]+://)" # scheme
26 r"([a-z_\d-]+:[a-z_\d-]+@)?" # user:password
28 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
29 r"(:\d{2,})?" # port number
30 r"(/[a-z\d_%+-]*)*" # folders
31 r"(\.[a-z\d_%+-]+)*" # file extension
32 r"(\?[a-z\d_+%-=]*)?" # query string
36 URL_RE = re.compile(r"^{}$".format(URLS_RAW_STRING), re.IGNORECASE)
38 URLS_RE = re.compile(r"({})".format(URLS_RAW_STRING), re.IGNORECASE)
40 ESCAPED_AT_SIGN = re.compile(r'(?!"[^"]*)@+(?=[^"]*")|\\@')
42 EMAILS_RAW_STRING = r"[a-zA-Z\d._\+\-'`!%#$&*/=\?\^\{\}\|~\\]+@[a-z\d-]+\.?[a-z\d-]+\.[a-z]{2,4}"
44 EMAIL_RE = re.compile(r"^{}$".format(EMAILS_RAW_STRING))
46 EMAILS_RE = re.compile(r"({})".format(EMAILS_RAW_STRING))
48 CAMEL_CASE_TEST_RE = re.compile(
49 r"^[a-zA-Z]*([a-z]+[A-Z]+|[A-Z]+[a-z]+)[a-zA-Z\d]*$"
52 CAMEL_CASE_REPLACE_RE = re.compile(r"([a-z]|[A-Z]+)(?=[A-Z])")
54 SNAKE_CASE_TEST_RE = re.compile(
55 r"^([a-z]+\d*_[a-z\d_]*|_+[a-z\d]+[a-z\d_]*)$", re.IGNORECASE
58 SNAKE_CASE_TEST_DASH_RE = re.compile(
59 r"([a-z]+\d*-[a-z\d-]*|-+[a-z\d]+[a-z\d-]*)$", re.IGNORECASE
62 SNAKE_CASE_REPLACE_RE = re.compile(r"(_)([a-z\d])")
64 SNAKE_CASE_REPLACE_DASH_RE = re.compile(r"(-)([a-z\d])")
67 "VISA": re.compile(r"^4\d{12}(?:\d{3})?$"),
68 "MASTERCARD": re.compile(r"^5[1-5]\d{14}$"),
69 "AMERICAN_EXPRESS": re.compile(r"^3[47]\d{13}$"),
70 "DINERS_CLUB": re.compile(r"^3(?:0[0-5]|[68]\d)\d{11}$"),
71 "DISCOVER": re.compile(r"^6(?:011|5\d{2})\d{12}$"),
72 "JCB": re.compile(r"^(?:2131|1800|35\d{3})\d{11}$"),
75 JSON_WRAPPER_RE = re.compile(
76 r"^\s*[\[{]\s*(.*)\s*[\}\]]\s*$", re.MULTILINE | re.DOTALL
80 r"^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$", re.IGNORECASE
83 UUID_HEX_OK_RE = re.compile(
84 r"^[a-f\d]{8}-?[a-f\d]{4}-?[a-f\d]{4}-?[a-f\d]{4}-?[a-f\d]{12}$",
88 SHALLOW_IP_V4_RE = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
90 IP_V6_RE = re.compile(r"^([a-z\d]{0,4}:){7}[a-z\d]{0,4}$", re.IGNORECASE)
92 MAC_ADDRESS_RE = re.compile(
93 r"^([0-9A-F]{2}[:-]){5}([0-9A-F]{2})", re.IGNORECASE
96 WORDS_COUNT_RE = re.compile(
97 r"\W*[^\W_]+\W*", re.IGNORECASE | re.MULTILINE | re.UNICODE
100 HTML_RE = re.compile(
101 r"((<([a-z]+:)?[a-z]+[^>]*/?>)(.*?(</([a-z]+:)?[a-z]+>))?|<!--.*-->|<!doctype.*>)",
102 re.IGNORECASE | re.MULTILINE | re.DOTALL,
105 HTML_TAG_ONLY_RE = re.compile(
106 r"(<([a-z]+:)?[a-z]+[^>]*/?>|</([a-z]+:)?[a-z]+>|<!--.*-->|<!doctype.*>)",
107 re.IGNORECASE | re.MULTILINE | re.DOTALL,
110 SPACES_RE = re.compile(r"\s")
112 NO_LETTERS_OR_NUMBERS_RE = re.compile(
113 r"[^\w\d]+|_+", re.IGNORECASE | re.UNICODE
116 MARGIN_RE = re.compile(r"^[^\S\r\n]+")
118 ESCAPE_SEQUENCE_RE = re.compile(r"
\e\[[^A-Za-z]*[A-Za-z]")
134 def is_none_or_empty(in_str: Optional[str]) -> bool:
135 return in_str is None or len(in_str.strip()) == 0
138 def is_string(obj: Any) -> bool:
140 Checks if an object is a string.
142 return isinstance(obj, str)
145 def is_empty_string(in_str: Any) -> bool:
146 return is_string(in_str) and in_str.strip() == ""
149 def is_full_string(in_str: Any) -> bool:
150 return is_string(in_str) and in_str.strip() != ""
153 def is_number(in_str: str) -> bool:
155 Checks if a string is a valid number.
157 if not is_string(in_str):
158 raise ValueError(in_str)
159 return NUMBER_RE.match(in_str) is not None
162 def is_integer_number(in_str: str) -> bool:
164 Checks whether the given string represents an integer or not.
166 An integer may be signed or unsigned or use a "scientific notation".
170 >>> is_integer('42') # returns true
171 >>> is_integer('42.0') # returns false
174 (is_number(in_str) and "." not in in_str) or
175 is_hexidecimal_integer_number(in_str) or
176 is_octal_integer_number(in_str) or
177 is_binary_integer_number(in_str)
181 def is_hexidecimal_integer_number(in_str: str) -> bool:
182 if not is_string(in_str):
183 raise ValueError(in_str)
184 return HEX_NUMBER_RE.match(in_str) is not None
187 def is_octal_integer_number(in_str: str) -> bool:
188 if not is_string(in_str):
189 raise ValueError(in_str)
190 return OCT_NUMBER_RE.match(in_str) is not None
193 def is_binary_integer_number(in_str: str) -> bool:
194 if not is_string(in_str):
195 raise ValueError(in_str)
196 return BIN_NUMBER_RE.match(in_str) is not None
199 def to_int(in_str: str) -> int:
200 if not is_string(in_str):
201 raise ValueError(in_str)
202 if is_binary_integer_number(in_str):
203 return int(in_str, 2)
204 if is_octal_integer_number(in_str):
205 return int(in_str, 8)
206 if is_hexidecimal_integer_number(in_str):
207 return int(in_str, 16)
211 def is_decimal_number(in_str: str) -> bool:
213 Checks whether the given string represents a decimal or not.
215 A decimal may be signed or unsigned or use a "scientific notation".
217 >>> is_decimal('42.0') # returns true
218 >>> is_decimal('42') # returns false
220 return is_number(in_str) and "." in in_str
223 def strip_escape_sequences(in_str: str) -> str:
224 in_str = ESCAPE_SEQUENCE_RE.sub("", in_str)
228 def add_thousands_separator(
231 separator_char = ',',
234 if isinstance(in_str, int):
236 if is_number(in_str):
237 return _add_thousands_separator(
239 separator_char = separator_char,
242 raise ValueError(in_str)
245 def _add_thousands_separator(in_str: str, *, separator_char = ',', places = 3) -> str:
248 (in_str, decimal_part) = in_str.split('.')
249 tmp = [iter(in_str[::-1])] * places
250 ret = separator_char.join(
251 "".join(x) for x in zip_longest(*tmp, fillvalue=""))[::-1]
252 if len(decimal_part) > 0:
259 # scheme://username:
[email protected]:8042/folder/subfolder/file.extension?param=value¶m2=value2#hash
260 def is_url(in_str: Any, allowed_schemes: Optional[List[str]] = None) -> bool:
262 Check if a string is a valid url.
266 >>> is_url('http://www.mysite.com') # returns true
267 >>> is_url('https://mysite.com') # returns true
268 >>> is_url('.mysite.com') # returns false
270 if not is_full_string(in_str):
273 valid = URL_RE.match(in_str) is not None
276 return valid and any([in_str.startswith(s) for s in allowed_schemes])
280 def is_email(in_str: Any) -> bool:
282 Check if a string is a valid email.
284 Reference: https://tools.ietf.org/html/rfc3696#section-3
289 >>> is_email('@gmail.com') # returns false
292 not is_full_string(in_str)
294 or in_str.startswith(".")
299 # we expect 2 tokens, one before "@" and one after, otherwise
300 # we have an exception and the email is not valid.
301 head, tail = in_str.split("@")
303 # head's size must be <= 64, tail <= 255, head must not start
304 # with a dot or contain multiple consecutive dots.
308 or head.endswith(".")
313 # removes escaped spaces, so that later on the test regex will
315 head = head.replace("\\ ", "")
316 if head.startswith('"') and head.endswith('"'):
317 head = head.replace(" ", "")[1:-1]
318 return EMAIL_RE.match(head + "@" + tail) is not None
321 # borderline case in which we have multiple "@" signs but the
322 # head part is correctly escaped.
323 if ESCAPED_AT_SIGN.search(in_str) is not None:
324 # replace "@" with "a" in the head
325 return is_email(ESCAPED_AT_SIGN.sub("a", in_str))
329 def suffix_string_to_number(in_str: str) -> Optional[int]:
330 """Take a string like "33Gb" and convert it into a number (of bytes)
331 like 34603008. Return None if the input string is not valid.
334 def suffix_capitalize(s: str) -> str:
338 return f"{s[0].upper()}{s[1].lower()}"
339 return suffix_capitalize(s[0:1])
341 if is_string(in_str):
342 if is_integer_number(in_str):
343 return to_int(in_str)
344 suffixes = [in_str[-2:], in_str[-1:]]
345 rest = [in_str[:-2], in_str[:-1]]
346 for x in range(len(suffixes)):
348 s = suffix_capitalize(s)
349 multiplier = NUM_SUFFIXES.get(s, None)
350 if multiplier is not None:
352 if is_integer_number(r):
353 return int(r) * multiplier
357 def number_to_suffix_string(num: int) -> Optional[str]:
358 """Take a number (of bytes) and returns a string like "43.8Gb".
359 Returns none if the input is invalid.
363 for (sfx, size) in NUM_SUFFIXES.items():
368 if suffix is not None:
369 return f"{d:.1f}{suffix}"
374 def is_credit_card(in_str: Any, card_type: str = None) -> bool:
376 Checks if a string is a valid credit card number.
377 If card type is provided then it checks against that specific type only,
378 otherwise any known credit card number will be accepted.
380 Supported card types are the following:
389 if not is_full_string(in_str):
392 if card_type is not None:
393 if card_type not in CREDIT_CARDS:
395 f'Invalid card type "{card_type}". Valid types are: {CREDIT_CARDS.keys()}'
397 return CREDIT_CARDS[card_type].match(in_str) is not None
398 for c in CREDIT_CARDS:
399 if CREDIT_CARDS[c].match(in_str) is not None:
404 def is_camel_case(in_str: Any) -> bool:
406 Checks if a string is formatted as camel case.
408 A string is considered camel case when:
410 - it's composed only by letters ([a-zA-Z]) and optionally numbers ([0-9])
411 - it contains both lowercase and uppercase letters
412 - it does not start with a number
415 is_full_string(in_str) and CAMEL_CASE_TEST_RE.match(in_str) is not None
419 def is_snake_case(in_str: Any, *, separator: str = "_") -> bool:
421 Checks if a string is formatted as "snake case".
423 A string is considered snake case when:
425 - it's composed only by lowercase/uppercase letters and digits
426 - it contains at least one underscore (or provided separator)
427 - it does not start with a number
429 if is_full_string(in_str):
430 re_map = {"_": SNAKE_CASE_TEST_RE, "-": SNAKE_CASE_TEST_DASH_RE}
432 r"([a-z]+\d*{sign}[a-z\d{sign}]*|{sign}+[a-z\d]+[a-z\d{sign}]*)"
437 re_template.format(sign=re.escape(separator)), re.IGNORECASE
440 return r.match(in_str) is not None
444 def is_json(in_str: Any) -> bool:
446 Check if a string is a valid json.
450 >>> is_json('{"name": "Peter"}') # returns true
451 >>> is_json('[1, 2, 3]') # returns true
452 >>> is_json('{nope}') # returns false
454 if is_full_string(in_str) and JSON_WRAPPER_RE.match(in_str) is not None:
456 return isinstance(json.loads(in_str), (dict, list))
457 except (TypeError, ValueError, OverflowError):
462 def is_uuid(in_str: Any, allow_hex: bool = False) -> bool:
464 Check if a string is a valid UUID.
468 >>> is_uuid('6f8aa2f9-686c-4ac3-8766-5712354a04cf') # returns true
469 >>> is_uuid('6f8aa2f9686c4ac387665712354a04cf') # returns false
470 >>> is_uuid('6f8aa2f9686c4ac387665712354a04cf', allow_hex=True) # returns true
472 # string casting is used to allow UUID itself as input data type
475 return UUID_HEX_OK_RE.match(s) is not None
476 return UUID_RE.match(s) is not None
479 def is_ip_v4(in_str: Any) -> bool:
481 Checks if a string is a valid ip v4.
485 >>> is_ip_v4('255.200.100.75') # returns true
486 >>> is_ip_v4('nope') # returns false (not an ip)
487 >>> is_ip_v4('255.200.100.999') # returns false (999 is out of range)
489 if not is_full_string(in_str) or SHALLOW_IP_V4_RE.match(in_str) is None:
492 # checks that each entry in the ip is in the valid range (0 to 255)
493 for token in in_str.split("."):
494 if not 0 <= int(token) <= 255:
499 def extract_ip_v4(in_str: Any) -> Optional[str]:
501 Extracts the IPv4 chunk of a string or None.
503 if not is_full_string(in_str):
506 m = SHALLOW_IP_V4_RE.match(in_str)
512 def is_ip_v6(in_str: Any) -> bool:
514 Checks if a string is a valid ip v6.
518 >>> is_ip_v6('2001:db8:85a3:0000:0000:8a2e:370:7334') # returns true
519 >>> is_ip_v6('2001:db8:85a3:0000:0000:8a2e:370:?') # returns false (invalid "?")
521 return is_full_string(in_str) and IP_V6_RE.match(in_str) is not None
524 def extract_ip_v6(in_str: Any) -> Optional[str]:
526 Extract IPv6 chunk or None.
528 if not is_full_string(in_str):
531 m = IP_V6_RE.match(in_str)
537 def is_ip(in_str: Any) -> bool:
539 Checks if a string is a valid ip (either v4 or v6).
543 >>> is_ip('255.200.100.75') # returns true
544 >>> is_ip('2001:db8:85a3:0000:0000:8a2e:370:7334') # returns true
545 >>> is_ip('1.2.3') # returns false
547 return is_ip_v6(in_str) or is_ip_v4(in_str)
550 def extract_ip(in_str: Any) -> Optional[str]:
551 """Extract the IP address or None."""
552 ip = extract_ip_v4(in_str)
554 ip = extract_ip_v6(in_str)
558 def is_mac_address(in_str: Any) -> bool:
559 """Return True if in_str is a valid MAC address false otherwise."""
560 return is_full_string(in_str) and MAC_ADDRESS_RE.match(in_str) is not None
563 def extract_mac_address(in_str: Any, *, separator: str = ":") -> Optional[str]:
564 """Extract the MAC address from in_str"""
565 if not is_full_string(in_str):
568 m = MAC_ADDRESS_RE.match(in_str)
571 mac.replace(":", separator)
572 mac.replace("-", separator)
577 def is_slug(in_str: Any, separator: str = "-") -> bool:
579 Checks if a given string is a slug (as created by `slugify()`).
583 >>> is_slug('my-blog-post-title') # returns true
584 >>> is_slug('My blog post title') # returns false
586 :param in_str: String to check.
588 :param separator: Join sign used by the slug.
590 :return: True if slug, false otherwise.
592 if not is_full_string(in_str):
594 rex = r"^([a-z\d]+" + re.escape(separator) + r"*?)*[a-z\d]$"
595 return re.match(rex, in_str) is not None
598 def contains_html(in_str: str) -> bool:
600 Checks if the given string contains HTML/XML tags.
602 By design, this function matches ANY type of tag, so don't expect to use it
603 as an HTML validator, its goal is to detect "malicious" or undesired tags in the text.
607 >>> contains_html('my string is <strong>bold</strong>') # returns true
608 >>> contains_html('my string is not bold') # returns false
610 if not is_string(in_str):
611 raise ValueError(in_str)
612 return HTML_RE.search(in_str) is not None
615 def words_count(in_str: str) -> int:
617 Returns the number of words contained into the given string.
619 This method is smart, it does consider only sequence of one or more letter and/or numbers
620 as "words", so a string like this: "! @ # % ... []" will return zero!
621 Moreover it is aware of punctuation, so the count for a string like "one,two,three.stop"
622 will be 4 not 1 (even if there are no spaces in the string).
626 >>> words_count('hello world') # returns 2
627 >>> words_count('one,two,three.stop') # returns 4
629 if not is_string(in_str):
630 raise ValueError(in_str)
631 return len(WORDS_COUNT_RE.findall(in_str))
634 def generate_uuid(as_hex: bool = False) -> str:
636 Generated an UUID string (using `uuid.uuid4()`).
640 >>> uuid() # possible output: '97e3a716-6b33-4ab9-9bb1-8128cb24d76b'
641 >>> uuid(as_hex=True) # possible output: '97e3a7166b334ab99bb18128cb24d76b'
649 def generate_random_alphanumeric_string(size: int) -> str:
651 Returns a string of the specified size containing random
652 characters (uppercase/lowercase ascii letters and digits).
656 >>> random_string(9) # possible output: "cx3QQbzYg"
659 raise ValueError("size must be >= 1")
660 chars = string.ascii_letters + string.digits
661 buffer = [random.choice(chars) for _ in range(size)]
662 return from_char_list(buffer)
665 def reverse(in_str: str) -> str:
667 Returns the string with its chars reversed.
669 if not is_string(in_str):
670 raise ValueError(in_str)
674 def camel_case_to_snake_case(in_str, *, separator="_"):
676 Convert a camel case string into a snake case one.
677 (The original string is returned if is not a valid camel case string)
679 if not is_string(in_str):
680 raise ValueError(in_str)
681 if not is_camel_case(in_str):
683 return CAMEL_CASE_REPLACE_RE.sub(
684 lambda m: m.group(1) + separator, in_str
688 def snake_case_to_camel_case(
689 in_str: str, *, upper_case_first: bool = True, separator: str = "_"
692 Convert a snake case string into a camel case one.
693 (The original string is returned if is not a valid snake case string)
695 if not is_string(in_str):
696 raise ValueError(in_str)
697 if not is_snake_case(in_str, separator=separator):
699 tokens = [s.title() for s in in_str.split(separator) if is_full_string(s)]
700 if not upper_case_first:
701 tokens[0] = tokens[0].lower()
702 return from_char_list(tokens)
705 def to_char_list(in_str: str) -> List[str]:
706 if not is_string(in_str):
711 def from_char_list(in_list: List[str]) -> str:
712 return "".join(in_list)
715 def shuffle(in_str: str) -> str:
716 """Return a new string containing same chars of the given one but in
719 if not is_string(in_str):
720 raise ValueError(in_str)
722 # turn the string into a list of chars
723 chars = to_char_list(in_str)
724 random.shuffle(chars)
725 return from_char_list(chars)
728 def strip_html(in_str: str, keep_tag_content: bool = False) -> str:
730 Remove html code contained into the given string.
734 >>> strip_html('test: <a href="foo/bar">click here</a>') # returns 'test: '
735 >>> strip_html('test: <a href="foo/bar">click here</a>', keep_tag_content=True) # returns 'test: click here'
737 if not is_string(in_str):
738 raise ValueError(in_str)
739 r = HTML_TAG_ONLY_RE if keep_tag_content else HTML_RE
740 return r.sub("", in_str)
743 def asciify(in_str: str) -> str:
745 Force string content to be ascii-only by translating all non-ascii chars into the closest possible representation
746 (eg: ó -> o, Ë -> E, ç -> c...).
748 **Bear in mind**: Some chars may be lost if impossible to translate.
752 >>> asciify('èéùúòóäåëýñÅÀÁÇÌÍÑÓË') # returns 'eeuuooaaeynAAACIINOE'
754 if not is_string(in_str):
755 raise ValueError(in_str)
757 # "NFKD" is the algorithm which is able to successfully translate
758 # the most of non-ascii chars.
759 normalized = unicodedata.normalize("NFKD", in_str)
761 # encode string forcing ascii and ignore any errors
762 # (unrepresentable chars will be stripped out)
763 ascii_bytes = normalized.encode("ascii", "ignore")
765 # turns encoded bytes into an utf-8 string
766 return ascii_bytes.decode("utf-8")
769 def slugify(in_str: str, *, separator: str = "-") -> str:
771 Converts a string into a "slug" using provided separator.
772 The returned string has the following properties:
775 - all letters are in lower case
776 - all punctuation signs and non alphanumeric chars are removed
777 - words are divided using provided separator
778 - all chars are encoded as ascii (by using `asciify()`)
783 >>> slugify('Top 10 Reasons To Love Dogs!!!') # returns: 'top-10-reasons-to-love-dogs'
784 >>> slugify('Mönstér Mägnët') # returns 'monster-magnet'
786 if not is_string(in_str):
787 raise ValueError(in_str)
789 # replace any character that is NOT letter or number with spaces
790 out = NO_LETTERS_OR_NUMBERS_RE.sub(" ", in_str.lower()).strip()
792 # replace spaces with join sign
793 out = SPACES_RE.sub(separator, out)
795 # normalize joins (remove duplicates)
796 out = re.sub(re.escape(separator) + r"+", separator, out)
800 def to_bool(in_str: str) -> bool:
802 Turns a string into a boolean based on its content (CASE INSENSITIVE).
804 A positive boolean (True) is returned if the string value is one of the following:
811 Otherwise False is returned.
813 if not is_string(in_str):
814 raise ValueError(in_str)
815 return in_str.lower() in ("true", "1", "yes", "y", "t")
818 def to_date(in_str: str) -> Optional[datetime.date]:
819 import dateparse.dateparse_utils as dp
824 except dp.ParseException:
825 logger.warning(f'Unable to parse date {in_str}.')
829 def valid_date(in_str: str) -> bool:
830 import dateparse.dateparse_utils as dp
835 except dp.ParseException:
836 logger.warning(f'Unable to parse date {in_str}.')
840 def to_datetime(in_str: str) -> Optional[datetime.datetime]:
841 import dateparse.dateparse_utils as dp
845 if type(dt) == datetime.datetime:
848 logger.warning(f'Unable to parse datetime {in_str}.')
852 def valid_datetime(in_str: str) -> bool:
853 _ = to_datetime(in_str)
856 logger.warning(f'Unable to parse datetime {in_str}.')
860 def dedent(in_str: str) -> str:
862 Removes tab indentation from multi line strings (inspired by analogous Scala function).
878 if not is_string(in_str):
879 raise ValueError(in_str)
880 line_separator = '\n'
881 lines = [MARGIN_RE.sub('', line) for line in in_str.split(line_separator)]
882 return line_separator.join(lines)
885 def indent(in_str: str, amount: int) -> str:
886 if not is_string(in_str):
887 raise ValueError(in_str)
888 line_separator = '\n'
889 lines = [" " * amount + line for line in in_str.split(line_separator)]
890 return line_separator.join(lines)
893 def sprintf(*args, **kwargs) -> str:
896 sep = kwargs.pop("sep", None)
898 if not isinstance(sep, str):
899 raise TypeError("sep must be None or a string")
901 end = kwargs.pop("end", None)
903 if not isinstance(end, str):
904 raise TypeError("end must be None or a string")
907 raise TypeError("invalid keyword arguments to sprint()")
913 for i, arg in enumerate(args):
916 if isinstance(arg, str):
924 def is_are(n: int) -> str:
930 def pluralize(n: int) -> str:
936 def thify(n: int) -> str:
938 assert is_integer_number(digit)