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(in_str: str, *, separator_char = ',', places = 3) -> str:
229 if isinstance(in_str, int):
232 if is_number(in_str):
233 return _add_thousands_separator(
235 separator_char = separator_char,
238 raise ValueError(in_str)
241 def _add_thousands_separator(in_str: str, *, separator_char = ',', places = 3) -> str:
244 (in_str, decimal_part) = in_str.split('.')
245 tmp = [iter(in_str[::-1])] * places
246 ret = separator_char.join(
247 "".join(x) for x in zip_longest(*tmp, fillvalue=""))[::-1]
248 if len(decimal_part) > 0:
255 # scheme://username:
[email protected]:8042/folder/subfolder/file.extension?param=value¶m2=value2#hash
256 def is_url(in_str: Any, allowed_schemes: Optional[List[str]] = None) -> bool:
258 Check if a string is a valid url.
262 >>> is_url('http://www.mysite.com') # returns true
263 >>> is_url('https://mysite.com') # returns true
264 >>> is_url('.mysite.com') # returns false
266 if not is_full_string(in_str):
269 valid = URL_RE.match(in_str) is not None
272 return valid and any([in_str.startswith(s) for s in allowed_schemes])
276 def is_email(in_str: Any) -> bool:
278 Check if a string is a valid email.
280 Reference: https://tools.ietf.org/html/rfc3696#section-3
285 >>> is_email('@gmail.com') # returns false
288 not is_full_string(in_str)
290 or in_str.startswith(".")
295 # we expect 2 tokens, one before "@" and one after, otherwise
296 # we have an exception and the email is not valid.
297 head, tail = in_str.split("@")
299 # head's size must be <= 64, tail <= 255, head must not start
300 # with a dot or contain multiple consecutive dots.
304 or head.endswith(".")
309 # removes escaped spaces, so that later on the test regex will
311 head = head.replace("\\ ", "")
312 if head.startswith('"') and head.endswith('"'):
313 head = head.replace(" ", "")[1:-1]
314 return EMAIL_RE.match(head + "@" + tail) is not None
317 # borderline case in which we have multiple "@" signs but the
318 # head part is correctly escaped.
319 if ESCAPED_AT_SIGN.search(in_str) is not None:
320 # replace "@" with "a" in the head
321 return is_email(ESCAPED_AT_SIGN.sub("a", in_str))
325 def suffix_string_to_number(in_str: str) -> Optional[int]:
326 """Take a string like "33Gb" and convert it into a number (of bytes)
327 like 34603008. Return None if the input string is not valid.
330 def suffix_capitalize(s: str) -> str:
334 return f"{s[0].upper()}{s[1].lower()}"
335 return suffix_capitalize(s[0:1])
337 if is_string(in_str):
338 if is_integer_number(in_str):
339 return to_int(in_str)
340 suffixes = [in_str[-2:], in_str[-1:]]
341 rest = [in_str[:-2], in_str[:-1]]
342 for x in range(len(suffixes)):
344 s = suffix_capitalize(s)
345 multiplier = NUM_SUFFIXES.get(s, None)
346 if multiplier is not None:
348 if is_integer_number(r):
349 return int(r) * multiplier
353 def number_to_suffix_string(num: int) -> Optional[str]:
354 """Take a number (of bytes) and returns a string like "43.8Gb".
355 Returns none if the input is invalid.
359 for (sfx, size) in NUM_SUFFIXES.items():
364 if suffix is not None:
365 return f"{d:.1f}{suffix}"
370 def is_credit_card(in_str: Any, card_type: str = None) -> bool:
372 Checks if a string is a valid credit card number.
373 If card type is provided then it checks against that specific type only,
374 otherwise any known credit card number will be accepted.
376 Supported card types are the following:
385 if not is_full_string(in_str):
388 if card_type is not None:
389 if card_type not in CREDIT_CARDS:
391 f'Invalid card type "{card_type}". Valid types are: {CREDIT_CARDS.keys()}'
393 return CREDIT_CARDS[card_type].match(in_str) is not None
394 for c in CREDIT_CARDS:
395 if CREDIT_CARDS[c].match(in_str) is not None:
400 def is_camel_case(in_str: Any) -> bool:
402 Checks if a string is formatted as camel case.
404 A string is considered camel case when:
406 - it's composed only by letters ([a-zA-Z]) and optionally numbers ([0-9])
407 - it contains both lowercase and uppercase letters
408 - it does not start with a number
411 is_full_string(in_str) and CAMEL_CASE_TEST_RE.match(in_str) is not None
415 def is_snake_case(in_str: Any, *, separator: str = "_") -> bool:
417 Checks if a string is formatted as "snake case".
419 A string is considered snake case when:
421 - it's composed only by lowercase/uppercase letters and digits
422 - it contains at least one underscore (or provided separator)
423 - it does not start with a number
425 if is_full_string(in_str):
426 re_map = {"_": SNAKE_CASE_TEST_RE, "-": SNAKE_CASE_TEST_DASH_RE}
428 r"([a-z]+\d*{sign}[a-z\d{sign}]*|{sign}+[a-z\d]+[a-z\d{sign}]*)"
433 re_template.format(sign=re.escape(separator)), re.IGNORECASE
436 return r.match(in_str) is not None
440 def is_json(in_str: Any) -> bool:
442 Check if a string is a valid json.
446 >>> is_json('{"name": "Peter"}') # returns true
447 >>> is_json('[1, 2, 3]') # returns true
448 >>> is_json('{nope}') # returns false
450 if is_full_string(in_str) and JSON_WRAPPER_RE.match(in_str) is not None:
452 return isinstance(json.loads(in_str), (dict, list))
453 except (TypeError, ValueError, OverflowError):
458 def is_uuid(in_str: Any, allow_hex: bool = False) -> bool:
460 Check if a string is a valid UUID.
464 >>> is_uuid('6f8aa2f9-686c-4ac3-8766-5712354a04cf') # returns true
465 >>> is_uuid('6f8aa2f9686c4ac387665712354a04cf') # returns false
466 >>> is_uuid('6f8aa2f9686c4ac387665712354a04cf', allow_hex=True) # returns true
468 # string casting is used to allow UUID itself as input data type
471 return UUID_HEX_OK_RE.match(s) is not None
472 return UUID_RE.match(s) is not None
475 def is_ip_v4(in_str: Any) -> bool:
477 Checks if a string is a valid ip v4.
481 >>> is_ip_v4('255.200.100.75') # returns true
482 >>> is_ip_v4('nope') # returns false (not an ip)
483 >>> is_ip_v4('255.200.100.999') # returns false (999 is out of range)
485 if not is_full_string(in_str) or SHALLOW_IP_V4_RE.match(in_str) is None:
488 # checks that each entry in the ip is in the valid range (0 to 255)
489 for token in in_str.split("."):
490 if not 0 <= int(token) <= 255:
495 def extract_ip_v4(in_str: Any) -> Optional[str]:
497 Extracts the IPv4 chunk of a string or None.
499 if not is_full_string(in_str):
502 m = SHALLOW_IP_V4_RE.match(in_str)
508 def is_ip_v6(in_str: Any) -> bool:
510 Checks if a string is a valid ip v6.
514 >>> is_ip_v6('2001:db8:85a3:0000:0000:8a2e:370:7334') # returns true
515 >>> is_ip_v6('2001:db8:85a3:0000:0000:8a2e:370:?') # returns false (invalid "?")
517 return is_full_string(in_str) and IP_V6_RE.match(in_str) is not None
520 def extract_ip_v6(in_str: Any) -> Optional[str]:
522 Extract IPv6 chunk or None.
524 if not is_full_string(in_str):
527 m = IP_V6_RE.match(in_str)
533 def is_ip(in_str: Any) -> bool:
535 Checks if a string is a valid ip (either v4 or v6).
539 >>> is_ip('255.200.100.75') # returns true
540 >>> is_ip('2001:db8:85a3:0000:0000:8a2e:370:7334') # returns true
541 >>> is_ip('1.2.3') # returns false
543 return is_ip_v6(in_str) or is_ip_v4(in_str)
546 def extract_ip(in_str: Any) -> Optional[str]:
547 """Extract the IP address or None."""
548 ip = extract_ip_v4(in_str)
550 ip = extract_ip_v6(in_str)
554 def is_mac_address(in_str: Any) -> bool:
555 """Return True if in_str is a valid MAC address false otherwise."""
556 return is_full_string(in_str) and MAC_ADDRESS_RE.match(in_str) is not None
559 def extract_mac_address(in_str: Any, *, separator: str = ":") -> Optional[str]:
560 """Extract the MAC address from in_str"""
561 if not is_full_string(in_str):
564 m = MAC_ADDRESS_RE.match(in_str)
567 mac.replace(":", separator)
568 mac.replace("-", separator)
573 def is_slug(in_str: Any, separator: str = "-") -> bool:
575 Checks if a given string is a slug (as created by `slugify()`).
579 >>> is_slug('my-blog-post-title') # returns true
580 >>> is_slug('My blog post title') # returns false
582 :param in_str: String to check.
584 :param separator: Join sign used by the slug.
586 :return: True if slug, false otherwise.
588 if not is_full_string(in_str):
590 rex = r"^([a-z\d]+" + re.escape(separator) + r"*?)*[a-z\d]$"
591 return re.match(rex, in_str) is not None
594 def contains_html(in_str: str) -> bool:
596 Checks if the given string contains HTML/XML tags.
598 By design, this function matches ANY type of tag, so don't expect to use it
599 as an HTML validator, its goal is to detect "malicious" or undesired tags in the text.
603 >>> contains_html('my string is <strong>bold</strong>') # returns true
604 >>> contains_html('my string is not bold') # returns false
606 if not is_string(in_str):
607 raise ValueError(in_str)
608 return HTML_RE.search(in_str) is not None
611 def words_count(in_str: str) -> int:
613 Returns the number of words contained into the given string.
615 This method is smart, it does consider only sequence of one or more letter and/or numbers
616 as "words", so a string like this: "! @ # % ... []" will return zero!
617 Moreover it is aware of punctuation, so the count for a string like "one,two,three.stop"
618 will be 4 not 1 (even if there are no spaces in the string).
622 >>> words_count('hello world') # returns 2
623 >>> words_count('one,two,three.stop') # returns 4
625 if not is_string(in_str):
626 raise ValueError(in_str)
627 return len(WORDS_COUNT_RE.findall(in_str))
630 def generate_uuid(as_hex: bool = False) -> str:
632 Generated an UUID string (using `uuid.uuid4()`).
636 >>> uuid() # possible output: '97e3a716-6b33-4ab9-9bb1-8128cb24d76b'
637 >>> uuid(as_hex=True) # possible output: '97e3a7166b334ab99bb18128cb24d76b'
645 def generate_random_alphanumeric_string(size: int) -> str:
647 Returns a string of the specified size containing random
648 characters (uppercase/lowercase ascii letters and digits).
652 >>> random_string(9) # possible output: "cx3QQbzYg"
655 raise ValueError("size must be >= 1")
656 chars = string.ascii_letters + string.digits
657 buffer = [random.choice(chars) for _ in range(size)]
658 return from_char_list(buffer)
661 def reverse(in_str: str) -> str:
663 Returns the string with its chars reversed.
665 if not is_string(in_str):
666 raise ValueError(in_str)
670 def camel_case_to_snake_case(in_str, *, separator="_"):
672 Convert a camel case string into a snake case one.
673 (The original string is returned if is not a valid camel case string)
675 if not is_string(in_str):
676 raise ValueError(in_str)
677 if not is_camel_case(in_str):
679 return CAMEL_CASE_REPLACE_RE.sub(
680 lambda m: m.group(1) + separator, in_str
684 def snake_case_to_camel_case(
685 in_str: str, *, upper_case_first: bool = True, separator: str = "_"
688 Convert a snake case string into a camel case one.
689 (The original string is returned if is not a valid snake case string)
691 if not is_string(in_str):
692 raise ValueError(in_str)
693 if not is_snake_case(in_str, separator=separator):
695 tokens = [s.title() for s in in_str.split(separator) if is_full_string(s)]
696 if not upper_case_first:
697 tokens[0] = tokens[0].lower()
698 return from_char_list(tokens)
701 def to_char_list(in_str: str) -> List[str]:
702 if not is_string(in_str):
707 def from_char_list(in_list: List[str]) -> str:
708 return "".join(in_list)
711 def shuffle(in_str: str) -> str:
712 """Return a new string containing same chars of the given one but in
715 if not is_string(in_str):
716 raise ValueError(in_str)
718 # turn the string into a list of chars
719 chars = to_char_list(in_str)
720 random.shuffle(chars)
721 return from_char_list(chars)
724 def strip_html(in_str: str, keep_tag_content: bool = False) -> str:
726 Remove html code contained into the given string.
730 >>> strip_html('test: <a href="foo/bar">click here</a>') # returns 'test: '
731 >>> strip_html('test: <a href="foo/bar">click here</a>', keep_tag_content=True) # returns 'test: click here'
733 if not is_string(in_str):
734 raise ValueError(in_str)
735 r = HTML_TAG_ONLY_RE if keep_tag_content else HTML_RE
736 return r.sub("", in_str)
739 def asciify(in_str: str) -> str:
741 Force string content to be ascii-only by translating all non-ascii chars into the closest possible representation
742 (eg: ó -> o, Ë -> E, ç -> c...).
744 **Bear in mind**: Some chars may be lost if impossible to translate.
748 >>> asciify('èéùúòóäåëýñÅÀÁÇÌÍÑÓË') # returns 'eeuuooaaeynAAACIINOE'
750 if not is_string(in_str):
751 raise ValueError(in_str)
753 # "NFKD" is the algorithm which is able to successfully translate
754 # the most of non-ascii chars.
755 normalized = unicodedata.normalize("NFKD", in_str)
757 # encode string forcing ascii and ignore any errors
758 # (unrepresentable chars will be stripped out)
759 ascii_bytes = normalized.encode("ascii", "ignore")
761 # turns encoded bytes into an utf-8 string
762 return ascii_bytes.decode("utf-8")
765 def slugify(in_str: str, *, separator: str = "-") -> str:
767 Converts a string into a "slug" using provided separator.
768 The returned string has the following properties:
771 - all letters are in lower case
772 - all punctuation signs and non alphanumeric chars are removed
773 - words are divided using provided separator
774 - all chars are encoded as ascii (by using `asciify()`)
779 >>> slugify('Top 10 Reasons To Love Dogs!!!') # returns: 'top-10-reasons-to-love-dogs'
780 >>> slugify('Mönstér Mägnët') # returns 'monster-magnet'
782 if not is_string(in_str):
783 raise ValueError(in_str)
785 # replace any character that is NOT letter or number with spaces
786 out = NO_LETTERS_OR_NUMBERS_RE.sub(" ", in_str.lower()).strip()
788 # replace spaces with join sign
789 out = SPACES_RE.sub(separator, out)
791 # normalize joins (remove duplicates)
792 out = re.sub(re.escape(separator) + r"+", separator, out)
796 def to_bool(in_str: str) -> bool:
798 Turns a string into a boolean based on its content (CASE INSENSITIVE).
800 A positive boolean (True) is returned if the string value is one of the following:
807 Otherwise False is returned.
809 if not is_string(in_str):
810 raise ValueError(in_str)
811 return in_str.lower() in ("true", "1", "yes", "y", "t")
814 def to_date(in_str: str) -> Optional[datetime.date]:
815 import dateparse.dateparse_utils as dp
820 except dp.ParseException:
821 logger.warning(f'Unable to parse date {in_str}.')
825 def valid_date(in_str: str) -> bool:
826 import dateparse.dateparse_utils as dp
831 except dp.ParseException:
832 logger.warning(f'Unable to parse date {in_str}.')
836 def to_datetime(in_str: str) -> Optional[datetime.datetime]:
837 import dateparse.dateparse_utils as dp
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)