""" String utilities. """ import re from functools import lru_cache from typing import Any def remove_multispace(string: str) -> str: """Remove multiple spaces from a string, replacing them with a single space. Args: string: The string to remove multiple spaces from. Returns: The string with multiple spaces removed. Example: >>> remove_multispace("this is a test") 'this is a test' """ return re.sub(r"\s+", " ", string) def capitalize_title( text: str, keep_casing: bool = False, ignore_words: list[str] = None ) -> str: """Capitalize each word in a title, including hyphenated words. This is not the same as title() because it will capitalize the first letter of each word, even if it is a special character like a hyphen. Args: text: The text to capitalize. keep_casing: Whether to keep the casing of the text as is, except for the first letter of each word, which will always be capitalized. ignore_words: A list of words to ignore when capitalizing the title. Returns: The capitalized text. Example: >>> capitalize_title("this is a title") 'This Is A Title' >>> capitalize_title("this-is-a-title") 'This-Is-A-Title' >>> capitalize_title("this-is-a-TITLE", keep_casing=True) 'This-Is-A-TITLE' >>> capitalize_title("this-is-a-title", keep_casing=True, ignore_words=["title"]) 'This-Is-A-title' """ words = re.split(r"(\s|-)", text) return "".join( ( (word[0].upper() + (word[1:] if keep_casing else word[1:].lower())) if word not in set(ignore_words or []) else word ) for word in filter(bool, words) ) @lru_cache(maxsize=256) def snake_case(string: str) -> str: """Convert a camel string to snake case. Args: string: string to convert. Returns: The snake cased string. Example: >>> snake_case("assetId") 'asset_id' """ underscore: str = "_" return "".join( f"{underscore}{c.lower()}" if c.isupper() else c for c in string ).lstrip(underscore) def snake_case_mapping(mapping: dict[str, Any]) -> dict[str, Any]: """Convert a mapping to snake case recursively. It will also convert lists of mappings as well as mappings of mappings. Useful for converting responses from the YouTube API because they use camel case, into Python-friendly snake case. Args: mapping: A mapping of response data. Returns: A mapping with snake case keys. """ for k, v in mapping.items(): if isinstance(v, dict): mapping[k] = snake_case_mapping(v) elif v and isinstance(v, list) and isinstance(v[0], dict): mapping[k] = list(map(snake_case_mapping, v)) return {snake_case(k): v for k, v in mapping.items()} def var_precision(number: int | float, max_precision: int = 1) -> str: """Return a string representation of a number with variable precision. This implies that if the number is an integer, it will be returned as an integer, otherwise it will be returned as a float with the specified precision. If all decimals are 0, they will be removed and the number will be returned as an integer. Args: number: The number to convert to a percentage. max_precision: The maximum precision to use. Returns: The string representation of the number with variable precision. Example: >>> var_precision(3.0, 0) '3' >>> var_precision(3.14159, 2) '3.14' """ return f"{number:.{max(0, max_precision)}f}" if number % 1 else f"{int(number)}" def shorten_number(number: str | int | float) -> str: """Shorten a number to a human-readable format, e.g. 1,000 -> 1.0K, 1,000, 000 -> 1.0M. Will not shorten numbers less than 1000. Numbers over 1,000 will be shortened to 1 decimal place and rounded. Args: number: The number to shorten. Can be a string, int, or float. Returns: The shortened number, as a string. Example: >>> shorten_number(100) '100' >>> shorten_number(1000) '1.0K' >>> shorten_number(1000000) '1.0M' """ number = float(number) if number < 1000: if number % 1 == 0: return f"{int(number):,}" return f"{number:.1f}" if number < 1000000: return f"{number/1000:.1f}K" if number < 1000000000: return f"{number/1000000:.1f}M" raise NotImplementedError(f"Number {number} is too large to shorten.") # Dictionary mapping accented characters to their unaccented counterparts _accents_and_not: dict[str, str] = { "á": "a", "Á": "A", "à": "a", "À": "A", "â": "a", "Â": "A", "ä": "a", "Ä": "A", "ã": "a", "Ã": "A", "å": "a", "Å": "A", "é": "e", "É": "E", "è": "e", "È": "E", "ê": "e", "Ê": "E", "ë": "e", "Ë": "E", "í": "i", "Í": "I", "ì": "i", "Ì": "I", "î": "i", "Î": "I", "ï": "i", "Ï": "I", "ó": "o", "Ó": "O", "ò": "o", "Ò": "O", "ô": "o", "Ô": "O", "ö": "o", "Ö": "O", "õ": "o", "Õ": "O", "ø": "o", "Ø": "O", "ú": "u", "Ú": "U", "ù": "u", "Ù": "U", "û": "u", "Û": "U", "ü": "u", "Ü": "U", "ý": "y", "Ý": "Y", "ÿ": "y", "Ÿ": "Y", } def remove_accents(string: str) -> str: """Remove accents from a string. This function covers the most common accented characters in Western European languages, but might not be comprehensive enough for all languages. It is not based on library unidecode (which would be more comprehensive and also serve the same purpose), but rather a simple dictionary mapping of accented characters to their unaccented counterparts. This is because unidecode would also remove other non-ASCII characters, which is not desired in this case (e.g. ñ -> n, ç -> c). Args: string: The string to remove accents from. Returns: The string without accents. Example: >>> remove_accents("Café, mañana, façade, São Paulo") 'Cafe, mañana, façade, Sao Paulo' """ return "".join(_accents_and_not.get(char, char) for char in string)