6 from itertools import zip_longest
12 from typing import Any, Callable, List, Optional
14 from uuid import uuid4
16 logger = logging.getLogger(__name__)
18 NUMBER_RE = re.compile(r"^([+\-]?)((\d+)(\.\d+)?([e|E]\d+)?|\.\d+)$")
20 HEX_NUMBER_RE = re.compile(r"^([+|-]?)0[x|X]([0-9A-Fa-f]+)$")
22 OCT_NUMBER_RE = re.compile(r"^([+|-]?)0[O|o]([0-7]+)$")
24 BIN_NUMBER_RE = re.compile(r"^([+|-]?)0[B|b]([0|1]+)$")
27 r"([a-z-]+://)" # scheme
28 r"([a-z_\d-]+:[a-z_\d-]+@)?" # user:password
30 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
31 r"(:\d{2,})?" # port number
32 r"(/[a-z\d_%+-]*)*" # folders
33 r"(\.[a-z\d_%+-]+)*" # file extension
34 r"(\?[a-z\d_+%-=]*)?" # query string
38 URL_RE = re.compile(r"^{}$".format(URLS_RAW_STRING), re.IGNORECASE)
40 URLS_RE = re.compile(r"({})".format(URLS_RAW_STRING), re.IGNORECASE)
42 ESCAPED_AT_SIGN = re.compile(r'(?!"[^"]*)@+(?=[^"]*")|\\@')
44 EMAILS_RAW_STRING = r"[a-zA-Z\d._\+\-'`!%#$&*/=\?\^\{\}\|~\\]+@[a-z\d-]+\.?[a-z\d-]+\.[a-z]{2,4}"
46 EMAIL_RE = re.compile(r"^{}$".format(EMAILS_RAW_STRING))
48 EMAILS_RE = re.compile(r"({})".format(EMAILS_RAW_STRING))
50 CAMEL_CASE_TEST_RE = re.compile(
51 r"^[a-zA-Z]*([a-z]+[A-Z]+|[A-Z]+[a-z]+)[a-zA-Z\d]*$"
54 CAMEL_CASE_REPLACE_RE = re.compile(r"([a-z]|[A-Z]+)(?=[A-Z])")
56 SNAKE_CASE_TEST_RE = re.compile(
57 r"^([a-z]+\d*_[a-z\d_]*|_+[a-z\d]+[a-z\d_]*)$", re.IGNORECASE
60 SNAKE_CASE_TEST_DASH_RE = re.compile(
61 r"([a-z]+\d*-[a-z\d-]*|-+[a-z\d]+[a-z\d-]*)$", re.IGNORECASE
64 SNAKE_CASE_REPLACE_RE = re.compile(r"(_)([a-z\d])")
66 SNAKE_CASE_REPLACE_DASH_RE = re.compile(r"(-)([a-z\d])")
69 "VISA": re.compile(r"^4\d{12}(?:\d{3})?$"),
70 "MASTERCARD": re.compile(r"^5[1-5]\d{14}$"),
71 "AMERICAN_EXPRESS": re.compile(r"^3[47]\d{13}$"),
72 "DINERS_CLUB": re.compile(r"^3(?:0[0-5]|[68]\d)\d{11}$"),
73 "DISCOVER": re.compile(r"^6(?:011|5\d{2})\d{12}$"),
74 "JCB": re.compile(r"^(?:2131|1800|35\d{3})\d{11}$"),
77 JSON_WRAPPER_RE = re.compile(
78 r"^\s*[\[{]\s*(.*)\s*[\}\]]\s*$", re.MULTILINE | re.DOTALL
82 r"^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$", re.IGNORECASE
85 UUID_HEX_OK_RE = re.compile(
86 r"^[a-f\d]{8}-?[a-f\d]{4}-?[a-f\d]{4}-?[a-f\d]{4}-?[a-f\d]{12}$",
90 SHALLOW_IP_V4_RE = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
92 IP_V6_RE = re.compile(r"^([a-z\d]{0,4}:){7}[a-z\d]{0,4}$", re.IGNORECASE)
94 MAC_ADDRESS_RE = re.compile(
95 r"^([0-9A-F]{2}[:-]){5}([0-9A-F]{2})", re.IGNORECASE
98 WORDS_COUNT_RE = re.compile(
99 r"\W*[^\W_]+\W*", re.IGNORECASE | re.MULTILINE | re.UNICODE
102 HTML_RE = re.compile(
103 r"((<([a-z]+:)?[a-z]+[^>]*/?>)(.*?(</([a-z]+:)?[a-z]+>))?|<!--.*-->|<!doctype.*>)",
104 re.IGNORECASE | re.MULTILINE | re.DOTALL,
107 HTML_TAG_ONLY_RE = re.compile(
108 r"(<([a-z]+:)?[a-z]+[^>]*/?>|</([a-z]+:)?[a-z]+>|<!--.*-->|<!doctype.*>)",
109 re.IGNORECASE | re.MULTILINE | re.DOTALL,
112 SPACES_RE = re.compile(r"\s")
114 NO_LETTERS_OR_NUMBERS_RE = re.compile(
115 r"[^\w\d]+|_+", re.IGNORECASE | re.UNICODE
118 MARGIN_RE = re.compile(r"^[^\S\r\n]+")
120 ESCAPE_SEQUENCE_RE = re.compile(r"
\e\[[^A-Za-z]*[A-Za-z]")
136 def is_none_or_empty(in_str: Optional[str]) -> bool:
137 return in_str is None or len(in_str.strip()) == 0
140 def is_string(obj: Any) -> bool:
142 Checks if an object is a string.
144 return isinstance(obj, str)
147 def is_empty_string(in_str: Any) -> bool:
148 return is_string(in_str) and in_str.strip() == ""
151 def is_full_string(in_str: Any) -> bool:
152 return is_string(in_str) and in_str.strip() != ""
155 def is_number(in_str: str) -> bool:
157 Checks if a string is a valid number.
159 if not is_string(in_str):
160 raise ValueError(in_str)
161 return NUMBER_RE.match(in_str) is not None
164 def is_integer_number(in_str: str) -> bool:
166 Checks whether the given string represents an integer or not.
168 An integer may be signed or unsigned or use a "scientific notation".
172 >>> is_integer('42') # returns true
173 >>> is_integer('42.0') # returns false
176 (is_number(in_str) and "." not in in_str) or
177 is_hexidecimal_integer_number(in_str) or
178 is_octal_integer_number(in_str) or
179 is_binary_integer_number(in_str)
183 def is_hexidecimal_integer_number(in_str: str) -> bool:
184 if not is_string(in_str):
185 raise ValueError(in_str)
186 return HEX_NUMBER_RE.match(in_str) is not None
189 def is_octal_integer_number(in_str: str) -> bool:
190 if not is_string(in_str):
191 raise ValueError(in_str)
192 return OCT_NUMBER_RE.match(in_str) is not None
195 def is_binary_integer_number(in_str: str) -> bool:
196 if not is_string(in_str):
197 raise ValueError(in_str)
198 return BIN_NUMBER_RE.match(in_str) is not None
201 def to_int(in_str: str) -> int:
202 if not is_string(in_str):
203 raise ValueError(in_str)
204 if is_binary_integer_number(in_str):
205 return int(in_str, 2)
206 if is_octal_integer_number(in_str):
207 return int(in_str, 8)
208 if is_hexidecimal_integer_number(in_str):
209 return int(in_str, 16)
213 def is_decimal_number(in_str: str) -> bool:
215 Checks whether the given string represents a decimal or not.
217 A decimal may be signed or unsigned or use a "scientific notation".
219 >>> is_decimal('42.0') # returns true
220 >>> is_decimal('42') # returns false
222 return is_number(in_str) and "." in in_str
225 def strip_escape_sequences(in_str: str) -> str:
226 in_str = ESCAPE_SEQUENCE_RE.sub("", in_str)
230 def add_thousands_separator(
233 separator_char = ',',
236 if isinstance(in_str, int):
238 if is_number(in_str):
239 return _add_thousands_separator(
241 separator_char = separator_char,
244 raise ValueError(in_str)
247 def _add_thousands_separator(in_str: str, *, separator_char = ',', places = 3) -> str:
250 (in_str, decimal_part) = in_str.split('.')
251 tmp = [iter(in_str[::-1])] * places
252 ret = separator_char.join(
253 "".join(x) for x in zip_longest(*tmp, fillvalue=""))[::-1]
254 if len(decimal_part) > 0:
261 # scheme://username:
[email protected]:8042/folder/subfolder/file.extension?param=value¶m2=value2#hash
262 def is_url(in_str: Any, allowed_schemes: Optional[List[str]] = None) -> bool:
264 Check if a string is a valid url.
268 >>> is_url('http://www.mysite.com') # returns true
269 >>> is_url('https://mysite.com') # returns true
270 >>> is_url('.mysite.com') # returns false
272 if not is_full_string(in_str):
275 valid = URL_RE.match(in_str) is not None
278 return valid and any([in_str.startswith(s) for s in allowed_schemes])
282 def is_email(in_str: Any) -> bool:
284 Check if a string is a valid email.
286 Reference: https://tools.ietf.org/html/rfc3696#section-3
291 >>> is_email('@gmail.com') # returns false
294 not is_full_string(in_str)
296 or in_str.startswith(".")
301 # we expect 2 tokens, one before "@" and one after, otherwise
302 # we have an exception and the email is not valid.
303 head, tail = in_str.split("@")
305 # head's size must be <= 64, tail <= 255, head must not start
306 # with a dot or contain multiple consecutive dots.
310 or head.endswith(".")
315 # removes escaped spaces, so that later on the test regex will
317 head = head.replace("\\ ", "")
318 if head.startswith('"') and head.endswith('"'):
319 head = head.replace(" ", "")[1:-1]
320 return EMAIL_RE.match(head + "@" + tail) is not None
323 # borderline case in which we have multiple "@" signs but the
324 # head part is correctly escaped.
325 if ESCAPED_AT_SIGN.search(in_str) is not None:
326 # replace "@" with "a" in the head
327 return is_email(ESCAPED_AT_SIGN.sub("a", in_str))
331 def suffix_string_to_number(in_str: str) -> Optional[int]:
332 """Take a string like "33Gb" and convert it into a number (of bytes)
333 like 34603008. Return None if the input string is not valid.
336 def suffix_capitalize(s: str) -> str:
340 return f"{s[0].upper()}{s[1].lower()}"
341 return suffix_capitalize(s[0:1])
343 if is_string(in_str):
344 if is_integer_number(in_str):
345 return to_int(in_str)
346 suffixes = [in_str[-2:], in_str[-1:]]
347 rest = [in_str[:-2], in_str[:-1]]
348 for x in range(len(suffixes)):
350 s = suffix_capitalize(s)
351 multiplier = NUM_SUFFIXES.get(s, None)
352 if multiplier is not None:
354 if is_integer_number(r):
355 return int(r) * multiplier
359 def number_to_suffix_string(num: int) -> Optional[str]:
360 """Take a number (of bytes) and returns a string like "43.8Gb".
361 Returns none if the input is invalid.
365 for (sfx, size) in NUM_SUFFIXES.items():
370 if suffix is not None:
371 return f"{d:.1f}{suffix}"
376 def is_credit_card(in_str: Any, card_type: str = None) -> bool:
378 Checks if a string is a valid credit card number.
379 If card type is provided then it checks against that specific type only,
380 otherwise any known credit card number will be accepted.
382 Supported card types are the following:
391 if not is_full_string(in_str):
394 if card_type is not None:
395 if card_type not in CREDIT_CARDS:
397 f'Invalid card type "{card_type}". Valid types are: {CREDIT_CARDS.keys()}'
399 return CREDIT_CARDS[card_type].match(in_str) is not None
400 for c in CREDIT_CARDS:
401 if CREDIT_CARDS[c].match(in_str) is not None:
406 def is_camel_case(in_str: Any) -> bool:
408 Checks if a string is formatted as camel case.
410 A string is considered camel case when:
412 - it's composed only by letters ([a-zA-Z]) and optionally numbers ([0-9])
413 - it contains both lowercase and uppercase letters
414 - it does not start with a number
417 is_full_string(in_str) and CAMEL_CASE_TEST_RE.match(in_str) is not None
421 def is_snake_case(in_str: Any, *, separator: str = "_") -> bool:
423 Checks if a string is formatted as "snake case".
425 A string is considered snake case when:
427 - it's composed only by lowercase/uppercase letters and digits
428 - it contains at least one underscore (or provided separator)
429 - it does not start with a number
431 if is_full_string(in_str):
432 re_map = {"_": SNAKE_CASE_TEST_RE, "-": SNAKE_CASE_TEST_DASH_RE}
434 r"([a-z]+\d*{sign}[a-z\d{sign}]*|{sign}+[a-z\d]+[a-z\d{sign}]*)"
439 re_template.format(sign=re.escape(separator)), re.IGNORECASE
442 return r.match(in_str) is not None
446 def is_json(in_str: Any) -> bool:
448 Check if a string is a valid json.
452 >>> is_json('{"name": "Peter"}') # returns true
453 >>> is_json('[1, 2, 3]') # returns true
454 >>> is_json('{nope}') # returns false
456 if is_full_string(in_str) and JSON_WRAPPER_RE.match(in_str) is not None:
458 return isinstance(json.loads(in_str), (dict, list))
459 except (TypeError, ValueError, OverflowError):
464 def is_uuid(in_str: Any, allow_hex: bool = False) -> bool:
466 Check if a string is a valid UUID.
470 >>> is_uuid('6f8aa2f9-686c-4ac3-8766-5712354a04cf') # returns true
471 >>> is_uuid('6f8aa2f9686c4ac387665712354a04cf') # returns false
472 >>> is_uuid('6f8aa2f9686c4ac387665712354a04cf', allow_hex=True) # returns true
474 # string casting is used to allow UUID itself as input data type
477 return UUID_HEX_OK_RE.match(s) is not None
478 return UUID_RE.match(s) is not None
481 def is_ip_v4(in_str: Any) -> bool:
483 Checks if a string is a valid ip v4.
487 >>> is_ip_v4('255.200.100.75') # returns true
488 >>> is_ip_v4('nope') # returns false (not an ip)
489 >>> is_ip_v4('255.200.100.999') # returns false (999 is out of range)
491 if not is_full_string(in_str) or SHALLOW_IP_V4_RE.match(in_str) is None:
494 # checks that each entry in the ip is in the valid range (0 to 255)
495 for token in in_str.split("."):
496 if not 0 <= int(token) <= 255:
501 def extract_ip_v4(in_str: Any) -> Optional[str]:
503 Extracts the IPv4 chunk of a string or None.
505 if not is_full_string(in_str):
508 m = SHALLOW_IP_V4_RE.match(in_str)
514 def is_ip_v6(in_str: Any) -> bool:
516 Checks if a string is a valid ip v6.
520 >>> is_ip_v6('2001:db8:85a3:0000:0000:8a2e:370:7334') # returns true
521 >>> is_ip_v6('2001:db8:85a3:0000:0000:8a2e:370:?') # returns false (invalid "?")
523 return is_full_string(in_str) and IP_V6_RE.match(in_str) is not None
526 def extract_ip_v6(in_str: Any) -> Optional[str]:
528 Extract IPv6 chunk or None.
530 if not is_full_string(in_str):
533 m = IP_V6_RE.match(in_str)
539 def is_ip(in_str: Any) -> bool:
541 Checks if a string is a valid ip (either v4 or v6).
545 >>> is_ip('255.200.100.75') # returns true
546 >>> is_ip('2001:db8:85a3:0000:0000:8a2e:370:7334') # returns true
547 >>> is_ip('1.2.3') # returns false
549 return is_ip_v6(in_str) or is_ip_v4(in_str)
552 def extract_ip(in_str: Any) -> Optional[str]:
553 """Extract the IP address or None."""
554 ip = extract_ip_v4(in_str)
556 ip = extract_ip_v6(in_str)
560 def is_mac_address(in_str: Any) -> bool:
561 """Return True if in_str is a valid MAC address false otherwise."""
562 return is_full_string(in_str) and MAC_ADDRESS_RE.match(in_str) is not None
565 def extract_mac_address(in_str: Any, *, separator: str = ":") -> Optional[str]:
566 """Extract the MAC address from in_str"""
567 if not is_full_string(in_str):
570 m = MAC_ADDRESS_RE.match(in_str)
573 mac.replace(":", separator)
574 mac.replace("-", separator)
579 def is_slug(in_str: Any, separator: str = "-") -> bool:
581 Checks if a given string is a slug (as created by `slugify()`).
585 >>> is_slug('my-blog-post-title') # returns true
586 >>> is_slug('My blog post title') # returns false
588 :param in_str: String to check.
590 :param separator: Join sign used by the slug.
592 :return: True if slug, false otherwise.
594 if not is_full_string(in_str):
596 rex = r"^([a-z\d]+" + re.escape(separator) + r"*?)*[a-z\d]$"
597 return re.match(rex, in_str) is not None
600 def contains_html(in_str: str) -> bool:
602 Checks if the given string contains HTML/XML tags.
604 By design, this function matches ANY type of tag, so don't expect to use it
605 as an HTML validator, its goal is to detect "malicious" or undesired tags in the text.
609 >>> contains_html('my string is <strong>bold</strong>') # returns true
610 >>> contains_html('my string is not bold') # returns false
612 if not is_string(in_str):
613 raise ValueError(in_str)
614 return HTML_RE.search(in_str) is not None
617 def words_count(in_str: str) -> int:
619 Returns the number of words contained into the given string.
621 This method is smart, it does consider only sequence of one or more letter and/or numbers
622 as "words", so a string like this: "! @ # % ... []" will return zero!
623 Moreover it is aware of punctuation, so the count for a string like "one,two,three.stop"
624 will be 4 not 1 (even if there are no spaces in the string).
628 >>> words_count('hello world') # returns 2
629 >>> words_count('one,two,three.stop') # returns 4
631 if not is_string(in_str):
632 raise ValueError(in_str)
633 return len(WORDS_COUNT_RE.findall(in_str))
636 def generate_uuid(as_hex: bool = False) -> str:
638 Generated an UUID string (using `uuid.uuid4()`).
642 >>> uuid() # possible output: '97e3a716-6b33-4ab9-9bb1-8128cb24d76b'
643 >>> uuid(as_hex=True) # possible output: '97e3a7166b334ab99bb18128cb24d76b'
651 def generate_random_alphanumeric_string(size: int) -> str:
653 Returns a string of the specified size containing random
654 characters (uppercase/lowercase ascii letters and digits).
658 >>> random_string(9) # possible output: "cx3QQbzYg"
661 raise ValueError("size must be >= 1")
662 chars = string.ascii_letters + string.digits
663 buffer = [random.choice(chars) for _ in range(size)]
664 return from_char_list(buffer)
667 def reverse(in_str: str) -> str:
669 Returns the string with its chars reversed.
671 if not is_string(in_str):
672 raise ValueError(in_str)
676 def camel_case_to_snake_case(in_str, *, separator="_"):
678 Convert a camel case string into a snake case one.
679 (The original string is returned if is not a valid camel case string)
681 if not is_string(in_str):
682 raise ValueError(in_str)
683 if not is_camel_case(in_str):
685 return CAMEL_CASE_REPLACE_RE.sub(
686 lambda m: m.group(1) + separator, in_str
690 def snake_case_to_camel_case(
691 in_str: str, *, upper_case_first: bool = True, separator: str = "_"
694 Convert a snake case string into a camel case one.
695 (The original string is returned if is not a valid snake case string)
697 if not is_string(in_str):
698 raise ValueError(in_str)
699 if not is_snake_case(in_str, separator=separator):
701 tokens = [s.title() for s in in_str.split(separator) if is_full_string(s)]
702 if not upper_case_first:
703 tokens[0] = tokens[0].lower()
704 return from_char_list(tokens)
707 def to_char_list(in_str: str) -> List[str]:
708 if not is_string(in_str):
713 def from_char_list(in_list: List[str]) -> str:
714 return "".join(in_list)
717 def shuffle(in_str: str) -> str:
718 """Return a new string containing same chars of the given one but in
721 if not is_string(in_str):
722 raise ValueError(in_str)
724 # turn the string into a list of chars
725 chars = to_char_list(in_str)
726 random.shuffle(chars)
727 return from_char_list(chars)
730 def strip_html(in_str: str, keep_tag_content: bool = False) -> str:
732 Remove html code contained into the given string.
736 >>> strip_html('test: <a href="foo/bar">click here</a>') # returns 'test: '
737 >>> strip_html('test: <a href="foo/bar">click here</a>', keep_tag_content=True) # returns 'test: click here'
739 if not is_string(in_str):
740 raise ValueError(in_str)
741 r = HTML_TAG_ONLY_RE if keep_tag_content else HTML_RE
742 return r.sub("", in_str)
745 def asciify(in_str: str) -> str:
747 Force string content to be ascii-only by translating all non-ascii chars into the closest possible representation
748 (eg: ó -> o, Ë -> E, ç -> c...).
750 **Bear in mind**: Some chars may be lost if impossible to translate.
754 >>> asciify('èéùúòóäåëýñÅÀÁÇÌÍÑÓË') # returns 'eeuuooaaeynAAACIINOE'
756 if not is_string(in_str):
757 raise ValueError(in_str)
759 # "NFKD" is the algorithm which is able to successfully translate
760 # the most of non-ascii chars.
761 normalized = unicodedata.normalize("NFKD", in_str)
763 # encode string forcing ascii and ignore any errors
764 # (unrepresentable chars will be stripped out)
765 ascii_bytes = normalized.encode("ascii", "ignore")
767 # turns encoded bytes into an utf-8 string
768 return ascii_bytes.decode("utf-8")
771 def slugify(in_str: str, *, separator: str = "-") -> str:
773 Converts a string into a "slug" using provided separator.
774 The returned string has the following properties:
777 - all letters are in lower case
778 - all punctuation signs and non alphanumeric chars are removed
779 - words are divided using provided separator
780 - all chars are encoded as ascii (by using `asciify()`)
785 >>> slugify('Top 10 Reasons To Love Dogs!!!') # returns: 'top-10-reasons-to-love-dogs'
786 >>> slugify('Mönstér Mägnët') # returns 'monster-magnet'
788 if not is_string(in_str):
789 raise ValueError(in_str)
791 # replace any character that is NOT letter or number with spaces
792 out = NO_LETTERS_OR_NUMBERS_RE.sub(" ", in_str.lower()).strip()
794 # replace spaces with join sign
795 out = SPACES_RE.sub(separator, out)
797 # normalize joins (remove duplicates)
798 out = re.sub(re.escape(separator) + r"+", separator, out)
802 def to_bool(in_str: str) -> bool:
804 Turns a string into a boolean based on its content (CASE INSENSITIVE).
806 A positive boolean (True) is returned if the string value is one of the following:
813 Otherwise False is returned.
815 if not is_string(in_str):
816 raise ValueError(in_str)
817 return in_str.lower() in ("true", "1", "yes", "y", "t")
820 def to_date(in_str: str) -> Optional[datetime.date]:
821 import dateparse.dateparse_utils as dp
826 except dp.ParseException:
827 logger.warning(f'Unable to parse date {in_str}.')
831 def valid_date(in_str: str) -> bool:
832 import dateparse.dateparse_utils as dp
837 except dp.ParseException:
838 logger.warning(f'Unable to parse date {in_str}.')
842 def to_datetime(in_str: str) -> Optional[datetime.datetime]:
843 import dateparse.dateparse_utils as dp
847 if type(dt) == datetime.datetime:
850 logger.warning(f'Unable to parse datetime {in_str}.')
854 def valid_datetime(in_str: str) -> bool:
855 _ = to_datetime(in_str)
858 logger.warning(f'Unable to parse datetime {in_str}.')
862 def dedent(in_str: str) -> str:
864 Removes tab indentation from multi line strings (inspired by analogous Scala function).
880 if not is_string(in_str):
881 raise ValueError(in_str)
882 line_separator = '\n'
883 lines = [MARGIN_RE.sub('', line) for line in in_str.split(line_separator)]
884 return line_separator.join(lines)
887 def indent(in_str: str, amount: int) -> str:
888 if not is_string(in_str):
889 raise ValueError(in_str)
890 line_separator = '\n'
891 lines = [" " * amount + line for line in in_str.split(line_separator)]
892 return line_separator.join(lines)
895 def sprintf(*args, **kwargs) -> str:
898 sep = kwargs.pop("sep", None)
900 if not isinstance(sep, str):
901 raise TypeError("sep must be None or a string")
903 end = kwargs.pop("end", None)
905 if not isinstance(end, str):
906 raise TypeError("end must be None or a string")
909 raise TypeError("invalid keyword arguments to sprint()")
915 for i, arg in enumerate(args):
918 if isinstance(arg, str):
926 class SprintfStdout(object):
927 def __init__(self) -> None:
928 self.destination = io.StringIO()
931 def __enter__(self) -> Callable[[], str]:
932 self.recorder = contextlib.redirect_stdout(self.destination)
933 self.recorder.__enter__()
934 return lambda: self.destination.getvalue()
936 def __exit__(self, *args) -> None:
937 self.recorder.__exit__(*args)
938 self.destination.seek(0)
939 return None # don't suppress exceptions
942 def is_are(n: int) -> str:
948 def pluralize(n: int) -> str:
954 def thify(n: int) -> str:
956 assert is_integer_number(digit)