From 110c58f8bd1c070fd310a3fdf5a031e1d42b536a Mon Sep 17 00:00:00 2001 From: Alejandro Frias <3598338+AlejandroFrias@users.noreply.github.com> Date: Tue, 26 Aug 2025 13:16:32 -0700 Subject: [PATCH 1/4] update README and 100 percent coverage --- README.md | 69 ++++++++++++++++++++++++++++++++++----------- tests/test_utils.py | 6 ++++ 2 files changed, 59 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 3d9f68f..816c01e 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # Case Conversion -This is a port of the Sublime Text 3 plugin [CaseConversion](https://github.com/jdc0589/CaseConversion), by [Davis Clark's](https://github.com/jdc0589), to a regular python package. I couldn't find any other python packages on PyPI at the time (Feb 2016) that could seamlessly convert from any case to any other case without having to specify from what type of case I was converting. This plugin worked really well, so I separated the (non-sublime) python parts of the plugin into this useful python package. I also added Unicode support via python's `unicodedata`. +This is a port of the Sublime Text 3 plugin [CaseConversion](https://github.com/jdc0589/CaseConversion), by [Davis Clark](https://github.com/jdc0589), to a regular python package. I couldn't find any other python packages on PyPI at the time (Feb 2016) that could seamlessly convert from any case to any other case without having to specify from what type of case I was converting. This plugin worked really well, so I separated the (non-sublime) python parts of the plugin into this useful python package. I also added Unicode support via python's `unicodedata` and extended the interface some. ## Features @@ -13,8 +13,7 @@ This is a port of the Sublime Text 3 plugin [CaseConversion](https://github.com/ - Acronym detection *(no funky splitting on every capital letter of an all caps acronym like `HTTPError`!)* - Unicode supported (non-ASCII characters are first class citizens!) - Dependency free! -- Supports Python 3.6+ -- Over 95 percent test coverage and full type annotation. +- Supports Python 3.10+ - Every case conversion from/to you ever gonna need: - `camelCase` - `PascalCase` @@ -30,7 +29,41 @@ This is a port of the Sublime Text 3 plugin [CaseConversion](https://github.com/ ## Usage -Normal use is self-explanatory. + +### Converter Class + +Basic + +```python +>>> from case_conversion import Converter +>>> converter = Converter() +>>> converter.camel("FOO_BAR_STRING") +'fooBarString' +``` + +Initialize text when needing to convert the same text to multiple different cases. +```python +>>> from case_conversion import Converter +>>> converter = Converter(text="FOO_BAR_STRING") +>>> converter.camel() +'fooBarString' +>>> converter.pascal() +'FooBarString' +``` + +Initialize custom acronyms +```python +>>> from case_conversion import Converter +>>> converter = Converter(acronyms=["BAR"]) +>>> converter.camel("FOO_BAR_STRING") +'fooBARString' +``` + +### Convenience Functions + +For backwards compatibility and convenience, all converters are available as top level functions. They are all shorthand for: + +`Converter(text, acronyms).converter_function()` ```python >>> import case_conversion @@ -66,19 +99,24 @@ FÓÓ_BAR_STRING pip install case-conversion ``` - - ## Contribute Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. -This package is being developed with [poetry]([https://python-poetry.org/](https://python-poetry.org/)) (-> [docs]([https://python-poetry.org/docs/](https://python-poetry.org/docs/))). - -Before opening a pull request, please make sure to: - -- update tests as appropriate - -- `flake8`, `mypy` and `pytest` are happy +This package is being developed with [uv](https://github.com/astral-sh/uv) (-> [docs](https://docs.astral.sh/uv/)). + +CI will run tests and lint checks. +Locally you can run them with: +```bash +# runs tests with coverage +make test +# Runs linter (using ruff) +make lint +# Auto-fix linter errors (using ruff --fix) +make format +# run type check (using ty) +make tc +``` @@ -86,10 +124,9 @@ Before opening a pull request, please make sure to: Credit goes to [Davis Clark's](https://github.com/jdc0589) as the author of the original plugin and its contributors (Scott Bessler, Curtis Gibby, Matt Morrison). Thanks for their awesome work on making such a robust and awesome case converter. -Further credit goes to @olsonpm for making this package dependency-free. - +Further thanks and credit to [@olsonpm](https://github.com/olsonpm) for making this package dependency-free and encouraging package maintenance and best practices. -## Licence +## License Using [MIT licence](LICENSE.txt) with Davis Clark's Copyright diff --git a/tests/test_utils.py b/tests/test_utils.py index 37cea7d..c74b029 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -47,6 +47,11 @@ def test_sanitize_acronyms(acronyms, expected): assert utils.sanitize_acronyms(acronyms) == expected +def test_sanitize_acronyms_invalid(): + with pytest.raises(InvalidAcronymError): + utils.sanitize_acronyms(["HTTP", ""]) + + @pytest.mark.parametrize( "s,i,words,expected", ( @@ -65,6 +70,7 @@ def test_simple_acronym_detection(s, i, words, expected): # TODO: Add more cases (0, 1, ["FOO", "bar"], ("FOO",), 0), (0, 1, ["FOO", "bar"], ("BAR",), 2), + (0, 1, ["FOFOO"], ("FO", "FOO"), 2), ), ) def test_advanced_acronym_detection(s, i, words, acronyms, expected): From 397634a88eda950c99123cb41b904ae024cfe0ab Mon Sep 17 00:00:00 2001 From: Alejandro Frias <3598338+AlejandroFrias@users.noreply.github.com> Date: Wed, 27 Aug 2025 15:30:46 -0500 Subject: [PATCH 2/4] remove utils and types generic files for more specific file names --- README.md | 5 +- case_conversion/__init__.py | 9 +- case_conversion/acronym.py | 146 +++++++++++++++++++ case_conversion/alias.py | 54 +++++++ case_conversion/converter.py | 68 ++++----- case_conversion/parser.py | 139 ++++++++++++++---- case_conversion/types.py | 32 ----- case_conversion/unicode_char.py | 25 ++++ case_conversion/utils.py | 243 -------------------------------- tests/test_acronym.py | 65 +++++++++ tests/test_parser.py | 56 +++++--- tests/test_types.py | 10 -- tests/test_utils.py | 96 ------------- 13 files changed, 470 insertions(+), 478 deletions(-) create mode 100644 case_conversion/acronym.py create mode 100644 case_conversion/alias.py delete mode 100644 case_conversion/types.py create mode 100644 case_conversion/unicode_char.py delete mode 100644 case_conversion/utils.py create mode 100644 tests/test_acronym.py delete mode 100644 tests/test_types.py delete mode 100644 tests/test_utils.py diff --git a/README.md b/README.md index 816c01e..75c9fad 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ This is a port of the Sublime Text 3 plugin [CaseConversion](https://github.com/ ## Features -- Autodetection of case *(no need to specify explicitly which case you are converting from!)* +- Auto-detection of case *(no need to specify explicitly which case you are converting from!)* - Acronym detection *(no funky splitting on every capital letter of an all caps acronym like `HTTPError`!)* - Unicode supported (non-ASCII characters are first class citizens!) - Dependency free! @@ -26,6 +26,7 @@ This is a port of the Sublime Text 3 plugin [CaseConversion](https://github.com/ - `backslash\\case` - `Ada_Case` - `Http-Header-Case` + - ` ## Usage @@ -129,4 +130,4 @@ Further thanks and credit to [@olsonpm](https://github.com/olsonpm) for making t ## License -Using [MIT licence](LICENSE.txt) with Davis Clark's Copyright +Using [MIT license](LICENSE.txt) with Davis Clark's Copyright diff --git a/case_conversion/__init__.py b/case_conversion/__init__.py index ba75040..bc65da1 100644 --- a/case_conversion/__init__.py +++ b/case_conversion/__init__.py @@ -1,4 +1,4 @@ -from .converter import ( +from case_conversion.converter import ( camel, pascal, snake, @@ -16,8 +16,8 @@ http_header, Converter, ) -from .parser import parse_case -from .types import Case, InvalidAcronymError +from case_conversion.parser import parse_into_words +from case_conversion.acronym import InvalidAcronymError __all__ = [ "camel", @@ -35,8 +35,7 @@ "upper", "capitalize", "http_header", - "parse_case", - "Case", + "parse_into_words", "InvalidAcronymError", "Converter", ] diff --git a/case_conversion/acronym.py b/case_conversion/acronym.py new file mode 100644 index 0000000..fa22688 --- /dev/null +++ b/case_conversion/acronym.py @@ -0,0 +1,146 @@ +from typing import Iterator, TypeGuard + +from case_conversion.unicode_char import is_separator_char + + +class InvalidAcronymError(Exception): + """Raise when acronym fails validation.""" + + def __init__(self, acronym: str) -> None: # noqa: D107 + msg = f"Case Conversion: acronym '{acronym}' is invalid." + super().__init__(msg) + + +def find_substring_ranges(string: str, substring: str) -> Iterator[tuple[int, int]]: + """Finds (start, end) ranges of all occurrences of substring in string. + + >>> list(find_substring_ranges("foo_bar_bar", "bar")) + [(4, 7), (8, 11)] + """ + start = 0 + sub_len = len(substring) + while True: + start = string.find(substring, start) + if start == -1: + return + yield (start, start + sub_len) + start += 1 + + +def is_str_list(a_list: list[str | None]) -> TypeGuard[list[str]]: + return all(isinstance(item, str) for item in a_list) + + +def advanced_acronym_detection( + s: int, i: int, words: list[str | None], acronyms: list[str] +) -> int: + """Detect acronyms by checking against a list of acronyms. + + Arguments: + s (int): Index of first letter in run + i (int): Index of current word + words (list of str): Segmented input string + acronyms (list of str): List of acronyms + + Returns: + int: Index of last letter in run + """ + # Combine each letter into single string. + words_to_join = words[s:i] + assert is_str_list(words_to_join) + acr_str = "".join(words_to_join) + + # List of ranges representing found acronyms. + range_list: list[tuple[int, int]] = [] + # Set of remaining letters. + not_range = set(range(len(acr_str))) + + # Search for each acronym in acr_str. + for acr in acronyms: + for start, end in find_substring_ranges(acr_str, acr): + # Make sure found acronym doesn't overlap with others. + for r in range_list: + if start < r[1] and end > r[0]: + break + else: + range_list.append((start, end)) + for j in range(start, end): + not_range.remove(j) + + # Add remaining letters as ranges. + for nr in not_range: + range_list.append((nr, nr + 1)) + + # No ranges will overlap, so it's safe to sort by lower bound, + # which sort() will do by default. + range_list.sort() + + # Remove original letters in word list. + for _ in range(s, i): + del words[s] + + # Replace them with new word grouping. + for j in range(len(range_list)): + r = range_list[j] + words.insert(s + j, acr_str[r[0] : r[1]]) + + return s + len(range_list) - 1 + + +def simple_acronym_detection(s: int, i: int, words: list[str | None], *args) -> int: + """Detect acronyms based on runs of upper-case letters. + + Arguments: + s (int): Index of first letter in run + i (int): Index of current word + words (list of str): Segmented input string + args: Placeholder to conform to signature of + advanced_acronym_detection + + Returns: + int: Index of last letter in run + """ + # Combine each letter into a single string. + words_to_join = words[s:i] + assert is_str_list(words_to_join) + acr_str = "".join(words_to_join) + + # Remove original letters in word list. + for _ in range(s, i): + del words[s] + + # Replace them with new word grouping. + words.insert(s, "".join(acr_str)) + + return s + + +def is_valid_acronym(a_string: str) -> bool: + if not a_string: + return False + + for a_char in a_string: + if is_separator_char(a_char): + return False + + return True + + +def normalize_acronyms(unsafe_acronyms: list[str]) -> list[str]: + """Validates and normalizes acronyms to upper-case. + + Arguments: + unsafe_acronyms (list of str): Acronyms to be sanitized + + Returns: + list of str: Sanitized acronyms + + Raises: + InvalidAcronymError: Upon encountering an invalid acronym + """ + acronyms = [] + for acr in unsafe_acronyms: + if not is_valid_acronym(acr): + raise InvalidAcronymError(acr) + acronyms.append(acr.upper()) + return acronyms diff --git a/case_conversion/alias.py b/case_conversion/alias.py new file mode 100644 index 0000000..ab1a310 --- /dev/null +++ b/case_conversion/alias.py @@ -0,0 +1,54 @@ +"""Source: https://code.activestate.com/recipes/577659-decorators-for-adding-aliases-to-methods-in-a-clas/""" + + +class alias: + """ + Alias class that can be used as a decorator for making methods callable + through other names (or "aliases"). + Note: This decorator must be used inside an @aliased -decorated class. + For example, if you want to make the method shout() be also callable as + yell() and scream(), you can use alias like this: + + @alias('yell', 'scream') + def shout(message): + # .... + """ + + def __init__(self, *aliases): + """Constructor.""" + self.aliases = set(aliases) + + def __call__(self, f): + f._aliases = self.aliases + return f + + +def aliased(aliased_class): + """ + Decorator function that *must* be used in combination with @alias + decorator. This class will make the magic happen! + @aliased classes will have their aliased method (via @alias) actually + aliased. + This method simply iterates over the member attributes of 'aliased_class' + seeking for those which have an '_aliases' attribute and then defines new + members in the class using those aliases as mere pointer functions to the + original ones. + + Usage: + @aliased + class MyClass(object): + @alias('coolMethod', 'myKinkyMethod') + def boring_method(): + # ... + + i = MyClass() + i.coolMethod() # equivalent to i.myKinkyMethod() and i.boring_method() + """ + original_methods = aliased_class.__dict__.copy() + for name, method in original_methods.items(): + if hasattr(method, "_aliases"): + # Add the aliases for 'method', but don't override any + # previously-defined attribute of 'aliased_class' + for alias in method._aliases - set(original_methods): + setattr(aliased_class, alias, method) + return aliased_class diff --git a/case_conversion/converter.py b/case_conversion/converter.py index bc866f2..46c38f1 100644 --- a/case_conversion/converter.py +++ b/case_conversion/converter.py @@ -1,6 +1,8 @@ -from .parser import ParseData, parse_case +from case_conversion.alias import alias, aliased +from case_conversion.parser import ParsedWord, parse_into_words +@aliased class Converter: """Class style case converter that holds the core logic. @@ -18,16 +20,16 @@ class Converter: 'some-text-to-convert' """ - parse_data: ParseData | None + words: list[ParsedWord] | None text: str | None acronyms: list[str] def __init__(self, text: str | None = None, acronyms: list[str] | None = None): if text: - self.parse_data = parse_case(text, acronyms) + self.words = parse_into_words(text, acronyms) self.text = text else: - self.parse_data = None + self.words = None self.text = None self.acronyms = acronyms or [] @@ -53,16 +55,14 @@ def camel(self, text: str | None = None) -> str: """ if text: return Converter(text=text, acronyms=self.acronyms).camel() - elif self.parse_data: - words = self.parse_data.words - if not words: - return "" - camel_words = [word.normalized_word for word in words] + elif self.words: + camel_words = [word.normalized_word for word in self.words] camel_words[0] = camel_words[0].lower() return "".join(camel_words) return "" + @alias("mixed") def pascal(self, text: str | None = None) -> str: """Return text in PascalCase style. @@ -87,9 +87,8 @@ def pascal(self, text: str | None = None) -> str: """ if text: return Converter(text=text, acronyms=self.acronyms).pascal() - elif self.parse_data: - words = self.parse_data.words - return "".join([word.normalized_word for word in words]) + elif self.words: + return "".join([word.normalized_word for word in self.words]) return "" @@ -115,9 +114,8 @@ def snake(self, text: str | None = None) -> str: """ if text: return Converter(text=text, acronyms=self.acronyms).snake() - elif self.parse_data: - words = self.parse_data.words - return "_".join([word.normalized_word.lower() for word in words]) + elif self.words: + return "_".join([word.normalized_word.lower() for word in self.words]) return "" @@ -143,9 +141,8 @@ def dash(self, text: str | None = None) -> str: """ if text: return Converter(text=text, acronyms=self.acronyms).dash() - elif self.parse_data: - words = self.parse_data.words - return "-".join([word.normalized_word.lower() for word in words]) + elif self.words: + return "-".join([word.normalized_word.lower() for word in self.words]) return "" @@ -173,9 +170,8 @@ def const(self, text: str | None = None) -> str: """ if text: return Converter(text=text, acronyms=self.acronyms).const() - elif self.parse_data: - words = self.parse_data.words - return "_".join([word.normalized_word.upper() for word in words]) + elif self.words: + return "_".join([word.normalized_word.upper() for word in self.words]) return "" @@ -201,9 +197,8 @@ def dot(self, text: str | None = None) -> str: """ if text: return Converter(text=text, acronyms=self.acronyms).dot() - elif self.parse_data: - words = self.parse_data.words - return ".".join([word.normalized_word.lower() for word in words]) + elif self.words: + return ".".join([word.normalized_word.lower() for word in self.words]) return "" @@ -229,9 +224,8 @@ def separate_words(self, text: str | None = None) -> str: """ if text: return Converter(text=text, acronyms=self.acronyms).separate_words() - elif self.parse_data: - words = self.parse_data.words - return " ".join([word.original_word for word in words]) + elif self.words: + return " ".join([word.original_word for word in self.words]) return "" @@ -257,9 +251,8 @@ def slash(self, text: str | None = None) -> str: """ if text: return Converter(text=text, acronyms=self.acronyms).slash() - elif self.parse_data: - words = self.parse_data.words - return "/".join([word.original_word for word in words]) + elif self.words: + return "/".join([word.original_word for word in self.words]) return "" @@ -285,9 +278,8 @@ def backslash(self, text: str | None = None) -> str: """ if text: return Converter(text=text, acronyms=self.acronyms).backslash() - elif self.parse_data: - words = self.parse_data.words - return "\\".join([word.original_word for word in words]) + elif self.words: + return "\\".join([word.original_word for word in self.words]) return "" @@ -313,9 +305,8 @@ def ada(self, text: str | None = None) -> str: """ if text: return Converter(text=text, acronyms=self.acronyms).ada() - elif self.parse_data: - words = self.parse_data.words - return "_".join([w.normalized_word for w in words]) + elif self.words: + return "_".join([w.normalized_word for w in self.words]) return "" @@ -341,9 +332,8 @@ def http_header(self, text: str | None = None) -> str: """ if text: return Converter(text=text, acronyms=self.acronyms).http_header() - elif self.parse_data: - words = self.parse_data.words - return "-".join([w.normalized_word for w in words]) + elif self.words: + return "-".join([w.normalized_word for w in self.words]) return "" diff --git a/case_conversion/parser.py b/case_conversion/parser.py index 7161a8a..0d5d0aa 100644 --- a/case_conversion/parser.py +++ b/case_conversion/parser.py @@ -1,31 +1,106 @@ from dataclasses import dataclass -from .utils import ( + +from case_conversion.unicode_char import is_separator_char, is_upper_char +from case_conversion.acronym import ( advanced_acronym_detection, - is_upper, - normalize_word, - sanitize_acronyms, - segment_string, + normalize_acronyms, simple_acronym_detection, ) @dataclass -class Word: +class ParsedWord: original_word: str normalized_word: str -@dataclass -class ParseData: - words: list[Word] - original_separator: str +def segment_string(string: str) -> list[str | None]: + """Split the string into a list of strings + Segments are formed by breaking on capital letters and non-alphanumeric separators. + Separators are normalized to None within the list. + >>> segment_string('FOO_bar') + ['F', 'O', 'O', None, 'bar'] -def parse_case( + Arguments: + string (str): The string to process + + Returns: + list[str]: List of words the string got minced to + """ + words: list[str | None] = [] + separator = "" + + # curr_index of current character. Initially 1 because we don't + # want to check if the 0th character is a boundary. + curr_i = 1 + # Index of first character in a sequence + seq_i = 0 + # Previous character. + prev_i = string[0:1] + + # Treat an all-caps string as lower-case, to prevent its + # letters to be counted as boundaries + was_upper = False + if string.isupper(): + string = string.lower() + was_upper = True + + # Iterate over each character, checking for boundaries, or places + # where the string should divided. + while curr_i <= len(string): + char = string[curr_i : curr_i + 1] + split = False + if curr_i < len(string): + # Detect upper-case letter as boundary. + if is_upper_char(char): + split = True + # Detect transition from separator to not separator. + elif not is_separator_char(char) and is_separator_char(prev_i): + split = True + # Detect transition not separator to separator. + elif is_separator_char(char) and not is_separator_char(prev_i): + split = True + else: + # The loop goes one extra iteration so that it can + # handle the remaining text after the last boundary. + split = True + + if split: + if not is_separator_char(prev_i): + words.append(string[seq_i:curr_i]) + else: + # string contains at least one separator. + # Use the first one as the string's primary separator. + if not separator: + separator = string[seq_i : seq_i + 1] + + # Use None to indicate a separator in the word list. + words.append(None) + # If separators weren't included in the list, then breaks + # between upper-case sequences ("AAA_BBB") would be + # disregarded; the letter-run detector would count them + # as a single sequence ("AAABBB"). + seq_i = curr_i + + curr_i += 1 + prev_i = char + + if was_upper: + words = [word.upper() if word else None for word in words] + return words + + +def parse_into_words( string: str, acronyms: list[str] | None = None, -) -> ParseData: - """Split a string into words, determine its case and separator. +) -> list[ParsedWord]: + """Split a string into words, normalizing their values while respecting acronyms + for easy recombination into the various cases. + + The normalization is to capitalized words and all cap acronyms. + The original word is also retained for certain cases that preserve the original + words' capitalization. Args: string (str): Input string to be converted @@ -33,27 +108,26 @@ def parse_case( preserve_case (bool): Whether to preserve case of acronym Returns: - list of str: Segmented input string - Case: Determined case - str: Determined separator + list[ParsedWord]: list of parsed words (normalized and original) ready + to be combined into the desired case Examples: - >>> data = parse_case("hello_world") - >>> [word.normalized_word for word in data.words] + >>> words = parse_into_words("hello_world") + >>> [word.normalized_word for word in words] ['Hello', 'World'] - >>> [word.original_word for word in data.words] + >>> [word.original_word for word in words] ['hello', 'world'] - >>> data = parse_case("helloHTMLWorld", ["HTML"]) - >>> [word.normalized_word for word in data.words] + >>> words = parse_into_words("helloHTMLWorld", ["HTML"]) + >>> [word.normalized_word for word in words] ['Hello', 'HTML', 'World'] - >>> [word.original_word for word in data.words] + >>> [word.original_word for word in words] ['hello', 'HTML', 'World'] """ - words_with_sep, separator = segment_string(string) + words_with_sep = segment_string(string) if acronyms: # Use advanced acronym detection with list - acronyms = sanitize_acronyms(acronyms) + acronyms = normalize_acronyms(acronyms) check_acronym = advanced_acronym_detection else: acronyms = [] @@ -70,20 +144,25 @@ def parse_case( # Find runs of single upper-case letters. while i < len(words_with_sep): word = words_with_sep[i] - if word is not None and is_upper(word): + if word is not None and is_upper_char(word): if s is None: s = i elif s is not None: - i = check_acronym(s, i, words_with_sep, acronyms) + 1 # type: ignore + i = check_acronym(s, i, words_with_sep, acronyms) + 1 s = None i += 1 # Separators are no longer needed, so they should be removed. words: list[str] = [w for w in words_with_sep if w is not None] - word_list = [ - Word(original_word=word, normalized_word=normalize_word(word, acronyms)) + def normalize_word(word: str, acronyms: list[str]) -> str: + """Normalize word to capitalized or, for acronyms, all caps""" + if word.upper() in acronyms: + return word.upper() + else: + return word.capitalize() + + return [ + ParsedWord(original_word=word, normalized_word=normalize_word(word, acronyms)) for word in words ] - - return ParseData(words=word_list, original_separator=separator) diff --git a/case_conversion/types.py b/case_conversion/types.py deleted file mode 100644 index 1d8d24f..0000000 --- a/case_conversion/types.py +++ /dev/null @@ -1,32 +0,0 @@ -from enum import Enum, auto - - -class InvalidAcronymError(Exception): - """Raise when acronym fails validation.""" - - def __init__(self, acronym: str) -> None: # noqa: D107 - msg = f"Case Conversion: acronym '{acronym}' is invalid." - super().__init__(msg) - - -class Case(Enum): - """Enum representing case types. - - Members: - UNKNOWN: String contains no words. - UPPER: All words are upper-case. - LOWER: All words are lower-case. - PASCAL: All words are title- or upper-case. (String may still - have separators.) - CAMEL: First word is lower-case, the rest are title- or - upper-case. (String may still have separators.) - MIXED: Any other mixing of word casing. Never occurs if there - are no separators. - """ - - UNKNOWN = auto() - UPPER = auto() - LOWER = auto() - CAMEL = auto() - PASCAL = auto() - MIXED = auto() diff --git a/case_conversion/unicode_char.py b/case_conversion/unicode_char.py new file mode 100644 index 0000000..c8f2870 --- /dev/null +++ b/case_conversion/unicode_char.py @@ -0,0 +1,25 @@ +"""Unicode single character checkers""" + +import unicodedata + + +def is_separator_char(a_char: str) -> bool: + """Non-alphanumeric unicode character check.""" + return not ( + is_upper_char(a_char) or is_lower_char(a_char) or is_decimal_char(a_char) + ) + + +def is_decimal_char(a_char: str) -> bool: + """Numeric unicode character check.""" + return len(a_char) == 1 and unicodedata.category(a_char) == "Nd" + + +def is_lower_char(a_char: str) -> bool: + """Lowercase unicode character check.""" + return len(a_char) == 1 and unicodedata.category(a_char) == "Ll" + + +def is_upper_char(a_char: str) -> bool: + """Uppercase unicode character check.""" + return len(a_char) == 1 and unicodedata.category(a_char) == "Lu" diff --git a/case_conversion/utils.py b/case_conversion/utils.py deleted file mode 100644 index bd1709b..0000000 --- a/case_conversion/utils.py +++ /dev/null @@ -1,243 +0,0 @@ -import unicodedata -from typing import Iterator - -from .types import InvalidAcronymError - - -def get_substring_ranges(a_str: str, sub: str) -> Iterator[tuple[int, int]]: - start = 0 - sub_len = len(sub) - while True: - start = a_str.find(sub, start) - if start == -1: - return - yield (start, start + sub_len) - start += 1 - - -def char_is_sep(a_char: str) -> bool: - return not ( - char_is_upper(a_char) or char_is_lower(a_char) or char_is_decimal(a_char) - ) - - -def char_is_decimal(a_char: str) -> bool: - return unicodedata.category(a_char) == "Nd" - - -def char_is_lower(a_char: str) -> bool: - return unicodedata.category(a_char) == "Ll" - - -def char_is_upper(a_char: str) -> bool: - return unicodedata.category(a_char) == "Lu" - - -def is_upper(a_string: str) -> bool: - return len(a_string) == 1 and char_is_upper(a_string) - - -def is_valid_acronym(a_string: str) -> bool: - if not a_string: - return False - - for a_char in a_string: - if char_is_sep(a_char): - return False - - return True - - -def advanced_acronym_detection( - s: int, i: int, words: list[str], acronyms: list[str] -) -> int: - """Detect acronyms by checking against a list of acronyms. - - Arguments: - s (int): Index of first letter in run - i (int): Index of current word - words (list of str): Segmented input string - acronyms (list of str): List of acronyms - - Returns: - int: Index of last letter in run - """ - # Combine each letter into single string. - acr_str = "".join(words[s:i]) - - # List of ranges representing found acronyms. - range_list: list[tuple[int, int]] = [] - # Set of remaining letters. - not_range = set(range(len(acr_str))) - - # Search for each acronym in acr_str. - for acr in acronyms: - for start, end in get_substring_ranges(acr_str, acr): - # Make sure found acronym doesn't overlap with others. - for r in range_list: - if start < r[1] and end > r[0]: - break - else: - range_list.append((start, end)) - for j in range(start, end): - not_range.remove(j) - - # Add remaining letters as ranges. - for nr in not_range: - range_list.append((nr, nr + 1)) - - # No ranges will overlap, so it's safe to sort by lower bound, - # which sort() will do by default. - range_list.sort() - - # Remove original letters in word list. - for _ in range(s, i): - del words[s] - - # Replace them with new word grouping. - for j in range(len(range_list)): - r = range_list[j] - words.insert(s + j, acr_str[r[0] : r[1]]) - - return s + len(range_list) - 1 - - -def simple_acronym_detection(s: int, i: int, words: list[str], *args) -> int: - """Detect acronyms based on runs of upper-case letters. - - Arguments: - s (int): Index of first letter in run - i (int): Index of current word - words (list of str): Segmented input string - args: Placeholder to conform to signature of - advanced_acronym_detection - - Returns: - int: Index of last letter in run - """ - # Combine each letter into a single string. - acr_str = "".join(words[s:i]) - - # Remove original letters in word list. - for _ in range(s, i): - del words[s] - - # Replace them with new word grouping. - words.insert(s, "".join(acr_str)) - - return s - - -def sanitize_acronyms(unsafe_acronyms: list[str]) -> list[str]: - """Normalize valid acronyms to upper-case. - - Arguments: - unsafe_acronyms (list of str): Acronyms to be sanitized - - Returns: - list of str: Sanitized acronyms - - Raises: - InvalidAcronymError: Upon encountering an invalid acronym - """ - acronyms = [] - for acr in unsafe_acronyms: - if is_valid_acronym(acr): - acronyms.append(acr.upper()) - else: - raise InvalidAcronymError(acr) - return acronyms - - -def normalize_word(word: str, acronyms: list[str]) -> str: - """normalize word to capitalized or all caps for acronyms""" - if word.upper() in acronyms: - return word.upper() - else: - return word.capitalize() - - -def normalize_words(words: list[str], acronyms: list[str]) -> list[str]: - """Normalize case of each word to PascalCase. - - Arguments: - words (list of str): Words to normalize - acronyms (list of str): Acronyms to upper - - Returns: - list of str: Normalized words - """ - return [normalize_word(word, acronyms) for word in words] - - -def segment_string(string: str) -> tuple[list[str | None], str]: - """Segment string on separator into list of words. - - Arguments: - string (str): The string to process - - Returns: - optional, list of str: List of words the string got minced to - separator: The separator char intersecting words - """ - words: list[str | None] = [] - separator = "" - - # curr_index of current character. Initially 1 because we don't - # want to check if the 0th character is a boundary. - curr_i = 1 - # Index of first character in a sequence - seq_i = 0 - # Previous character. - prev_i = string[0:1] - - # Treat an all-caps string as lower-case, to prevent its - # letters to be counted as boundaries - was_upper = False - if string.isupper(): - string = string.lower() - was_upper = True - - # Iterate over each character, checking for boundaries, or places - # where the string should divided. - while curr_i <= len(string): - char = string[curr_i : curr_i + 1] - split = False - if curr_i < len(string): - # Detect upper-case letter as boundary. - if char_is_upper(char): - split = True - # Detect transition from separator to not separator. - elif not char_is_sep(char) and char_is_sep(prev_i): - split = True - # Detect transition not separator to separator. - elif char_is_sep(char) and not char_is_sep(prev_i): - split = True - else: - # The loop goes one extra iteration so that it can - # handle the remaining text after the last boundary. - split = True - - if split: - if not char_is_sep(prev_i): - words.append(string[seq_i:curr_i]) - else: - # string contains at least one separator. - # Use the first one as the string's primary separator. - if not separator: - separator = string[seq_i : seq_i + 1] - - # Use None to indicate a separator in the word list. - words.append(None) - # If separators weren't included in the list, then breaks - # between upper-case sequences ("AAA_BBB") would be - # disregarded; the letter-run detector would count them - # as a single sequence ("AAABBB"). - seq_i = curr_i - - curr_i += 1 - prev_i = char - - if was_upper: - words = [word.upper() if word else None for word in words] - return words, separator diff --git a/tests/test_acronym.py b/tests/test_acronym.py new file mode 100644 index 0000000..26fd28b --- /dev/null +++ b/tests/test_acronym.py @@ -0,0 +1,65 @@ +import pytest + +from case_conversion.acronym import ( + InvalidAcronymError, + advanced_acronym_detection, + normalize_acronyms, + simple_acronym_detection, +) + + +@pytest.mark.parametrize( + "acronyms,expected", + ( + (("http",), ["HTTP"]), + ( + ("HTTP",), + ["HTTP"], + ), + ( + ("Http",), + ["HTTP"], + ), + ( + ("httP",), + ["HTTP"], + ), + (("http", "Nasa"), ["HTTP", "NASA"]), + ), +) +def test_sanitize_acronyms(acronyms, expected): + assert normalize_acronyms(acronyms) == expected + + +def test_sanitize_acronyms_invalid(): + with pytest.raises(InvalidAcronymError): + normalize_acronyms(["HTTP", ""]) + + +@pytest.mark.parametrize( + "s,i,words,expected", + ( + (0, 1, ["FOO", "bar"], 0), + (1, 2, ["foo", "BAR", "baz"], 1), + ), +) +def test_simple_acronym_detection(s, i, words, expected): + assert simple_acronym_detection(s, i, words) == expected + + +@pytest.mark.parametrize( + "s,i,words,acronyms,expected", + ( + (0, 1, ["FOO", "bar"], ("FOO",), 0), + (0, 1, ["FOO", "bar"], ("BAR",), 2), + (0, 1, ["FOFOO"], ("FO", "FOO"), 2), + ), +) +def test_advanced_acronym_detection(s, i, words, acronyms, expected): + assert advanced_acronym_detection(s, i, words, acronyms) == expected + + +@pytest.mark.parametrize("acronyms", ("HT-TP", "NA SA", "SU.GAR")) +def test_sanitize_acronyms_raises_on_invalid_acronyms(acronyms): + with pytest.raises(InvalidAcronymError): + normalize_acronyms(acronyms) diff --git a/tests/test_parser.py b/tests/test_parser.py index bcfb8a1..61f8e04 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,37 +1,51 @@ import pytest -from case_conversion import parse_case -from case_conversion.parser import ParseData, Word +from case_conversion import parse_into_words +from case_conversion.parser import ParsedWord, segment_string @pytest.mark.parametrize( "string,acronyms,expected", - ( + [ ( "fooBarBaz", None, - ParseData( - words=[ - Word(original_word="foo", normalized_word="Foo"), - Word(original_word="Bar", normalized_word="Bar"), - Word(original_word="Baz", normalized_word="Baz"), - ], - original_separator="", - ), + [ + ParsedWord(original_word="foo", normalized_word="Foo"), + ParsedWord(original_word="Bar", normalized_word="Bar"), + ParsedWord(original_word="Baz", normalized_word="Baz"), + ], ), ( "fooBarBaz", ["BAR"], - ParseData( - words=[ - Word(original_word="foo", normalized_word="Foo"), - Word(original_word="Bar", normalized_word="BAR"), - Word(original_word="Baz", normalized_word="Baz"), - ], - original_separator="", - ), + [ + ParsedWord(original_word="foo", normalized_word="Foo"), + ParsedWord(original_word="Bar", normalized_word="BAR"), + ParsedWord(original_word="Baz", normalized_word="Baz"), + ], ), - ), + ], ) def test_parse_case(string, acronyms, expected): - assert parse_case(string, acronyms) == expected + assert parse_into_words(string, acronyms) == expected + + +@pytest.mark.parametrize( + "string,expected", + ( + ("fooBarString", ["foo", "Bar", "String"]), + ("FooBarString", ["Foo", "Bar", "String"]), + ("foo_bar_string", ["foo", None, "bar", None, "string"]), + ("foo-bar-string", ["foo", None, "bar", None, "string"]), + ("FOO_BAR_STRING", ["FOO", None, "BAR", None, "STRING"]), + ("foo.bar.string", ["foo", None, "bar", None, "string"]), + ("foo bar string", ["foo", None, "bar", None, "string"]), + ("foo/bar/string", ["foo", None, "bar", None, "string"]), + ("foo\\bar\\string", ["foo", None, "bar", None, "string"]), + ("foobarstring", ["foobarstring"]), + ("FOOBARSTRING", ["FOOBARSTRING"]), + ), +) +def test_segment_string(string, expected): + assert segment_string(string) == expected diff --git a/tests/test_types.py b/tests/test_types.py deleted file mode 100644 index 0bdb04b..0000000 --- a/tests/test_types.py +++ /dev/null @@ -1,10 +0,0 @@ -from case_conversion import InvalidAcronymError - - -def test_invalid_acronym_error_message(): - acronym = "BadAcronym" - msg = f"Case Conversion: acronym '{acronym}' is invalid." - try: - raise InvalidAcronymError(acronym) - except InvalidAcronymError as e: - assert msg in str(e) diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index c74b029..0000000 --- a/tests/test_utils.py +++ /dev/null @@ -1,96 +0,0 @@ -import pytest - -import case_conversion.utils as utils -from case_conversion import InvalidAcronymError - - -@pytest.mark.parametrize( - "string,expected", - ( - ("fooBarString", (["foo", "Bar", "String"], "")), - ("FooBarString", (["Foo", "Bar", "String"], "")), - ("foo_bar_string", (["foo", None, "bar", None, "string"], "_")), - ("foo-bar-string", (["foo", None, "bar", None, "string"], "-")), - ("FOO_BAR_STRING", (["FOO", None, "BAR", None, "STRING"], "_")), - ("foo.bar.string", (["foo", None, "bar", None, "string"], ".")), - ("foo bar string", (["foo", None, "bar", None, "string"], " ")), - ("foo/bar/string", (["foo", None, "bar", None, "string"], "/")), - ("foo\\bar\\string", (["foo", None, "bar", None, "string"], "\\")), - ("foobarstring", (["foobarstring"], "")), - ("FOOBARSTRING", (["FOOBARSTRING"], "")), - ), -) -def test_segment_string(string, expected): - assert utils.segment_string(string) == expected - - -@pytest.mark.parametrize( - "acronyms,expected", - ( - (("http",), ["HTTP"]), - ( - ("HTTP",), - ["HTTP"], - ), - ( - ("Http",), - ["HTTP"], - ), - ( - ("httP",), - ["HTTP"], - ), - (("http", "Nasa"), ["HTTP", "NASA"]), - ), -) -def test_sanitize_acronyms(acronyms, expected): - assert utils.sanitize_acronyms(acronyms) == expected - - -def test_sanitize_acronyms_invalid(): - with pytest.raises(InvalidAcronymError): - utils.sanitize_acronyms(["HTTP", ""]) - - -@pytest.mark.parametrize( - "s,i,words,expected", - ( - # TODO: Add more cases - (0, 1, ["FOO", "bar"], 0), - (1, 2, ["foo", "BAR", "baz"], 1), - ), -) -def test_simple_acronym_detection(s, i, words, expected): - assert utils.simple_acronym_detection(s, i, words) == expected - - -@pytest.mark.parametrize( - "s,i,words,acronyms,expected", - ( - # TODO: Add more cases - (0, 1, ["FOO", "bar"], ("FOO",), 0), - (0, 1, ["FOO", "bar"], ("BAR",), 2), - (0, 1, ["FOFOO"], ("FO", "FOO"), 2), - ), -) -def test_advanced_acronym_detection(s, i, words, acronyms, expected): - assert utils.advanced_acronym_detection(s, i, words, acronyms) == expected - - -@pytest.mark.parametrize("acronyms", ("HT-TP", "NA SA", "SU.GAR")) -def test_sanitize_acronyms_raises_on_invalid_acronyms(acronyms): - with pytest.raises(InvalidAcronymError): - utils.sanitize_acronyms(acronyms) - - -@pytest.mark.parametrize( - "words,acronyms,expected", - ( - (["foobar"], (), ["Foobar"]), - (["fooBar"], (), ["Foobar"]), - (["FooBar"], (), ["Foobar"]), - (["Foo", "Bar"], ("BAR"), ["Foo", "BAR"]), - ), -) -def test_normalize_words(words, acronyms, expected): - assert utils.normalize_words(words, acronyms) == expected From 0784c1f4468f14ce31a4f6e72df04a9af7c5ad31 Mon Sep 17 00:00:00 2001 From: Alejandro Frias <3598338+AlejandroFrias@users.noreply.github.com> Date: Wed, 27 Aug 2025 16:15:31 -0500 Subject: [PATCH 3/4] add mixed and screaming_snake aliases for pascal and const respectively --- case_conversion/__init__.py | 4 + case_conversion/alias.py | 11 ++- case_conversion/converter.py | 7 ++ tests/test_converter.py | 181 +++++++++++++++++++---------------- 4 files changed, 119 insertions(+), 84 deletions(-) diff --git a/case_conversion/__init__.py b/case_conversion/__init__.py index bc65da1..384ead2 100644 --- a/case_conversion/__init__.py +++ b/case_conversion/__init__.py @@ -1,9 +1,11 @@ from case_conversion.converter import ( camel, pascal, + mixed, snake, dash, const, + screaming_snake, dot, separate_words, slash, @@ -22,9 +24,11 @@ __all__ = [ "camel", "pascal", + "mixed", "snake", "dash", "const", + "screaming_snake", "dot", "separate_words", "slash", diff --git a/case_conversion/alias.py b/case_conversion/alias.py index ab1a310..58ae8b2 100644 --- a/case_conversion/alias.py +++ b/case_conversion/alias.py @@ -1,14 +1,19 @@ -"""Source: https://code.activestate.com/recipes/577659-decorators-for-adding-aliases-to-methods-in-a-clas/""" +"""""" class alias: - """ - Alias class that can be used as a decorator for making methods callable + """Alias class that can be used as a decorator for making methods callable through other names (or "aliases"). + + Source: https://code.activestate.com/recipes/577659-decorators-for-adding-aliases-to-methods-in-a-clas/ + Note: This decorator must be used inside an @aliased -decorated class. For example, if you want to make the method shout() be also callable as yell() and scream(), you can use alias like this: + @aliased + class Person: + @alias('yell', 'scream') def shout(message): # .... diff --git a/case_conversion/converter.py b/case_conversion/converter.py index 46c38f1..28b9381 100644 --- a/case_conversion/converter.py +++ b/case_conversion/converter.py @@ -146,6 +146,7 @@ def dash(self, text: str | None = None) -> str: return "" + @alias("screaming_snake") def const(self, text: str | None = None) -> str: """Return text in CONST_CASE style. @@ -509,6 +510,9 @@ def pascal(text: str, acronyms: list[str] | None = None) -> str: return Converter(text=text, acronyms=acronyms).pascal() +mixed = pascal + + def snake(text: str, acronyms: list[str] | None = None) -> str: """Return text in snake_case style. @@ -570,6 +574,9 @@ def const(text: str, acronyms: list[str] | None = None) -> str: return Converter(text=text, acronyms=acronyms).const() +screaming_snake = const + + def dot(text: str, acronyms: list[str] | None = None) -> str: """Return text in dot.case style. diff --git a/tests/test_converter.py b/tests/test_converter.py index da36c18..d85d1bb 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -8,7 +8,18 @@ ACRONYMS_UNICODE = ["HÉÉP"] # These cases do not preserve capitals from the original string -CASES = ["camel", "pascal", "snake", "dash", "const", "dot", "ada", "http_header"] +CASES = [ + "camel", + "pascal", + "mixed", + "snake", + "dash", + "const", + "screaming_snake", + "dot", + "ada", + "http_header", +] # These cases preserve the capitals from the original string CASES_PRESERVE = ["separate_words", "slash", "backslash"] @@ -16,9 +27,11 @@ VALUES = { "camel": "fooBarString", "pascal": "FooBarString", + "mixed": "FooBarString", "snake": "foo_bar_string", "dash": "foo-bar-string", "const": "FOO_BAR_STRING", + "screaming_snake": "FOO_BAR_STRING", "dot": "foo.bar.string", "separate_words": "foo bar string", "slash": "foo/bar/string", @@ -30,9 +43,11 @@ VALUES_UNICODE = { "camel": "fóoBarString", "pascal": "FóoBarString", + "mixed": "FóoBarString", "snake": "fóo_bar_string", "dash": "fóo-bar-string", "const": "FÓO_BAR_STRING", + "screaming_snake": "FÓO_BAR_STRING", "dot": "fóo.bar.string", "separate_words": "fóo bar string", "slash": "fóo/bar/string", @@ -44,9 +59,11 @@ VALUES_SINGLE = { "camel": "foo", "pascal": "Foo", + "mixed": "Foo", "snake": "foo", "dash": "foo", "const": "FOO", + "screaming_snake": "FOO", "dot": "foo", "separate_words": "foo", "slash": "foo", @@ -58,9 +75,11 @@ VALUES_SINGLE_UNICODE = { "camel": "fóo", "pascal": "Fóo", + "mixed": "Fóo", "snake": "fóo", "dash": "fóo", "const": "FÓO", + "screaming_snake": "FÓO", "dot": "fóo", "separate_words": "fóo", "slash": "fóo", @@ -72,9 +91,11 @@ VALUES_ACRONYM = { "camel": "fooHTTPBarString", "pascal": "FooHTTPBarString", + "mixed": "FooHTTPBarString", "snake": "foo_http_bar_string", "dash": "foo-http-bar-string", "const": "FOO_HTTP_BAR_STRING", + "screaming_snake": "FOO_HTTP_BAR_STRING", "dot": "foo.http.bar.string", "separate_words": "foo http bar string", "slash": "foo/http/bar/string", @@ -86,9 +107,11 @@ VALUES_ACRONYM_UNICODE = { "camel": "fooHÉÉPBarString", "pascal": "FooHÉÉPBarString", + "mixed": "FooHÉÉPBarString", "snake": "foo_héép_bar_string", "dash": "foo-héép-bar-string", "const": "FOO_HÉÉP_BAR_STRING", + "screaming_snake": "FOO_HÉÉP_BAR_STRING", "dot": "foo.héép.bar.string", "separate_words": "foo héép bar string", "slash": "foo/héép/bar/string", @@ -101,7 +124,9 @@ "separate_words": { "camel": "foo Bar String", "pascal": "Foo Bar String", + "mixed": "Foo Bar String", "const": "FOO BAR STRING", + "screaming_snake": "FOO BAR STRING", "ada": "Foo Bar String", "http_header": "Foo Bar String", "default": "foo bar string", @@ -109,7 +134,9 @@ "slash": { "camel": "foo/Bar/String", "pascal": "Foo/Bar/String", + "mixed": "Foo/Bar/String", "const": "FOO/BAR/STRING", + "screaming_snake": "FOO/BAR/STRING", "ada": "Foo/Bar/String", "http_header": "Foo/Bar/String", "default": "foo/bar/string", @@ -117,7 +144,9 @@ "backslash": { "camel": "foo\\Bar\\String", "pascal": "Foo\\Bar\\String", + "mixed": "Foo\\Bar\\String", "const": "FOO\\BAR\\STRING", + "screaming_snake": "FOO\\BAR\\STRING", "ada": "Foo\\Bar\\String", "http_header": "Foo\\Bar\\String", "default": "foo\\bar\\string", @@ -128,7 +157,9 @@ "separate_words": { "camel": "fóo Bar String", "pascal": "Fóo Bar String", + "mixed": "Fóo Bar String", "const": "FÓO BAR STRING", + "screaming_snake": "FÓO BAR STRING", "ada": "Fóo Bar String", "http_header": "Fóo Bar String", "default": "fóo bar string", @@ -136,7 +167,9 @@ "slash": { "camel": "fóo/Bar/String", "pascal": "Fóo/Bar/String", + "mixed": "Fóo/Bar/String", "const": "FÓO/BAR/STRING", + "screaming_snake": "FÓO/BAR/STRING", "ada": "Fóo/Bar/String", "http_header": "Fóo/Bar/String", "default": "fóo/bar/string", @@ -144,7 +177,9 @@ "backslash": { "camel": "fóo\\Bar\\String", "pascal": "Fóo\\Bar\\String", + "mixed": "Fóo\\Bar\\String", "const": "FÓO\\BAR\\STRING", + "screaming_snake": "FÓO\\BAR\\STRING", "ada": "Fóo\\Bar\\String", "http_header": "Fóo\\Bar\\String", "default": "fóo\\bar\\string", @@ -155,7 +190,9 @@ "separate_words": { "camel": "foo", "pascal": "Foo", + "mixed": "Foo", "const": "FOO", + "screaming_snake": "FOO", "ada": "Foo", "http_header": "Foo", "default": "foo", @@ -163,7 +200,9 @@ "slash": { "camel": "foo", "pascal": "Foo", + "mixed": "Foo", "const": "FOO", + "screaming_snake": "FOO", "ada": "Foo", "http_header": "Foo", "default": "foo", @@ -171,7 +210,9 @@ "backslash": { "camel": "foo", "pascal": "Foo", + "mixed": "Foo", "const": "FOO", + "screaming_snake": "FOO", "ada": "Foo", "http_header": "Foo", "default": "foo", @@ -182,7 +223,9 @@ "separate_words": { "camel": "fóo", "pascal": "Fóo", + "mixed": "Fóo", "const": "FÓO", + "screaming_snake": "FÓO", "ada": "Fóo", "http_header": "Fóo", "default": "fóo", @@ -190,7 +233,9 @@ "slash": { "camel": "fóo", "pascal": "Fóo", + "mixed": "Fóo", "const": "FÓO", + "screaming_snake": "FÓO", "ada": "Fóo", "http_header": "Fóo", "default": "fóo", @@ -198,7 +243,9 @@ "backslash": { "camel": "fóo", "pascal": "Fóo", + "mixed": "Fóo", "const": "FÓO", + "screaming_snake": "FÓO", "ada": "Fóo", "http_header": "Fóo", "default": "fóo", @@ -209,7 +256,9 @@ "separate_words": { "camel": "foo HTTP Bar String", "pascal": "Foo HTTP Bar String", + "mixed": "Foo HTTP Bar String", "const": "FOO HTTP BAR STRING", + "screaming_snake": "FOO HTTP BAR STRING", "ada": "Foo HTTP Bar String", "http_header": "Foo HTTP Bar String", "default": "foo http bar string", @@ -217,7 +266,9 @@ "slash": { "camel": "foo/HTTP/Bar/String", "pascal": "Foo/HTTP/Bar/String", + "mixed": "Foo/HTTP/Bar/String", "const": "FOO/HTTP/BAR/STRING", + "screaming_snake": "FOO/HTTP/BAR/STRING", "ada": "Foo/HTTP/Bar/String", "http_header": "Foo/HTTP/Bar/String", "default": "foo/http/bar/string", @@ -225,7 +276,9 @@ "backslash": { "camel": "foo\\HTTP\\Bar\\String", "pascal": "Foo\\HTTP\\Bar\\String", + "mixed": "Foo\\HTTP\\Bar\\String", "const": "FOO\\HTTP\\BAR\\STRING", + "screaming_snake": "FOO\\HTTP\\BAR\\STRING", "ada": "Foo\\HTTP\\Bar\\String", "http_header": "Foo\\HTTP\\Bar\\String", "default": "foo\\http\\bar\\string", @@ -236,7 +289,9 @@ "separate_words": { "camel": "foo HÉÉP Bar String", "pascal": "Foo HÉÉP Bar String", + "mixed": "Foo HÉÉP Bar String", "const": "FOO HÉÉP BAR STRING", + "screaming_snake": "FOO HÉÉP BAR STRING", "ada": "Foo HÉÉP Bar String", "http_header": "Foo HÉÉP Bar String", "default": "foo héép bar string", @@ -244,7 +299,9 @@ "slash": { "camel": "foo/HÉÉP/Bar/String", "pascal": "Foo/HÉÉP/Bar/String", + "mixed": "Foo/HÉÉP/Bar/String", "const": "FOO/HÉÉP/BAR/STRING", + "screaming_snake": "FOO/HÉÉP/BAR/STRING", "ada": "Foo/HÉÉP/Bar/String", "http_header": "Foo/HÉÉP/Bar/String", "default": "foo/héép/bar/string", @@ -252,7 +309,9 @@ "backslash": { "camel": "foo\\HÉÉP\\Bar\\String", "pascal": "Foo\\HÉÉP\\Bar\\String", + "mixed": "Foo\\HÉÉP\\Bar\\String", "const": "FOO\\HÉÉP\\BAR\\STRING", + "screaming_snake": "FOO\\HÉÉP\\BAR\\STRING", "ada": "Foo\\HÉÉP\\Bar\\String", "http_header": "Foo\\HÉÉP\\Bar\\String", "default": "foo\\héép\\bar\\string", @@ -264,7 +323,9 @@ "separate_words": { "camel": "HTTP", "pascal": "HTTP", + "mixed": "HTTP", "const": "HTTP", + "screaming_snake": "HTTP", "ada": "HTTP", "http_header": "HTTP", "default": "http", @@ -272,7 +333,9 @@ "slash": { "camel": "HTTP", "pascal": "HTTP", + "mixed": "HTTP", "const": "HTTP", + "screaming_snake": "HTTP", "ada": "HTTP", "http_header": "HTTP", "default": "http", @@ -280,14 +343,24 @@ "backslash": { "camel": "HTTP", "pascal": "HTTP", + "mixed": "HTTP", "const": "HTTP", + "screaming_snake": "HTTP", "ada": "HTTP", "http_header": "HTTP", "default": "http", }, } -CAPITAL_CASES = ["camel", "pascal", "const", "ada", "http_header"] +CAPITAL_CASES = [ + "camel", + "pascal", + "mixed", + "const", + "screaming_snake", + "ada", + "http_header", +] def _expand_values(values): @@ -322,45 +395,38 @@ def _expand_values_preserve(preserve_values, values): class CaseConversionTest(TestCase): - @parameterized.expand(_expand_values(VALUES)) - def test(self, _, case, value, expected): - """ - Test conversions from all cases to all cases that don't preserve - capital/lower case letters. - """ + def assertConverter( + self, case: str, value: str, expected: str, acronyms: list[str] | None = None + ): # test function style, e.g. snake("helloWorld") -> "hello_world" case_converter = getattr(case_conversion, case) - self.assertEqual(case_converter(value), expected) + self.assertEqual(case_converter(value, acronyms), expected) # test class style with init text, e.g. Converter("helloWorld").snake() -> "hello_world" - converter = case_conversion.Converter(text=value) + converter = case_conversion.Converter(text=value, acronyms=acronyms) case_converter = getattr(converter, case) self.assertEqual(case_converter(), expected) # test class style without init text, e.g. Converter().snake("helloWorld") -> "hello_world" - converter = case_conversion.Converter() + converter = case_conversion.Converter(acronyms=acronyms) case_converter = getattr(converter, case) self.assertEqual(case_converter(value), expected) + @parameterized.expand(_expand_values(VALUES)) + def test(self, _, case, value, expected): + """ + Test conversions from all cases to all cases that don't preserve + capital/lower case letters. + """ + self.assertConverter(case, value, expected) + @parameterized.expand(_expand_values(VALUES_UNICODE)) def test_unicode(self, _, case, value, expected): """ Test conversions from all cases to all cases that don't preserve capital/lower case letters (with unicode characters). """ - # test function style, e.g. snake("helloWorld") -> "hello_world" - case_converter = getattr(case_conversion, case) - self.assertEqual(case_converter(value), expected) - - # test class style with init text, e.g. Converter("helloWorld").snake() -> "hello_world" - converter = case_conversion.Converter(text=value) - case_converter = getattr(converter, case) - self.assertEqual(case_converter(), expected) - - # test class style without init text, e.g. Converter().snake("helloWorld") -> "hello_world" - converter = case_conversion.Converter() - case_converter = getattr(converter, case) - self.assertEqual(case_converter(value), expected) + self.assertConverter(case, value, expected) @parameterized.expand(_expand_values(VALUES_SINGLE)) def test_single(self, _, case, value, expected): @@ -368,19 +434,7 @@ def test_single(self, _, case, value, expected): Test conversions of single words from all cases to all cases that don't preserve capital/lower case letters. """ - # test function style, e.g. snake("helloWorld") -> "hello_world" - case_converter = getattr(case_conversion, case) - self.assertEqual(case_converter(value), expected) - - # test class style with init text, e.g. Converter("helloWorld").snake() -> "hello_world" - converter = case_conversion.Converter(text=value) - case_converter = getattr(converter, case) - self.assertEqual(case_converter(), expected) - - # test class style without init text, e.g. Converter().snake("helloWorld") -> "hello_world" - converter = case_conversion.Converter() - case_converter = getattr(converter, case) - self.assertEqual(case_converter(value), expected) + self.assertConverter(case, value, expected) @parameterized.expand(_expand_values(VALUES_SINGLE_UNICODE)) def test_single_unicode(self, _, case, value, expected): @@ -388,19 +442,7 @@ def test_single_unicode(self, _, case, value, expected): Test conversions of single words from all cases to all cases that don't preserve capital/lower case letters (with unicode characters). """ - # test function style, e.g. snake("helloWorld") -> "hello_world" - case_converter = getattr(case_conversion, case) - self.assertEqual(case_converter(value), expected) - - # test class style with init text, e.g. Converter("helloWorld").snake() -> "hello_world" - converter = case_conversion.Converter(text=value) - case_converter = getattr(converter, case) - self.assertEqual(case_converter(), expected) - - # test class style without init text, e.g. Converter().snake("helloWorld") -> "hello_world" - converter = case_conversion.Converter() - case_converter = getattr(converter, case) - self.assertEqual(case_converter(value), expected) + self.assertConverter(case, value, expected) @parameterized.expand(_expand_values_preserve(PRESERVE_VALUES, VALUES)) def test_preserve_case(self, _, case, value, expected): @@ -408,19 +450,7 @@ def test_preserve_case(self, _, case, value, expected): Test conversions from all cases to all cases that do preserve capital/lower case letters. """ - # test function style, e.g. snake("helloWorld") -> "hello_world" - case_converter = getattr(case_conversion, case) - self.assertEqual(case_converter(value), expected) - - # test class style with init text, e.g. Converter("helloWorld").snake() -> "hello_world" - converter = case_conversion.Converter(text=value) - case_converter = getattr(converter, case) - self.assertEqual(case_converter(), expected) - - # test class style without init text, e.g. Converter().snake("helloWorld") -> "hello_world" - converter = case_conversion.Converter() - case_converter = getattr(converter, case) - self.assertEqual(case_converter(value), expected) + self.assertConverter(case, value, expected) @parameterized.expand( _expand_values_preserve(PRESERVE_VALUES_UNICODE, VALUES_UNICODE) @@ -430,8 +460,7 @@ def test_preserve_case_unicode(self, _, case, value, expected): Test conversions from all cases to all cases that do preserve capital/lower case letters (with unicode characters). """ - case_converter = getattr(case_conversion, case) - self.assertEqual(case_converter(value), expected) + self.assertConverter(case, value, expected) @parameterized.expand( _expand_values_preserve(PRESERVE_VALUES_SINGLE, VALUES_SINGLE) @@ -441,8 +470,7 @@ def test_preserve_case_single(self, _, case, value, expected): Test conversions of single words from all cases to all cases that do preserve capital/lower case letters. """ - case_converter = getattr(case_conversion, case) - self.assertEqual(case_converter(value), expected) + self.assertConverter(case, value, expected) @parameterized.expand( _expand_values_preserve(PRESERVE_VALUES_SINGLE_UNICODE, VALUES_SINGLE_UNICODE) @@ -452,8 +480,7 @@ def test_preserve_case_single_unicode(self, _, case, value, expected): Test conversions of single words from all cases to all cases that do preserve capital/lower case letters (with unicode characters). """ - case_converter = getattr(case_conversion, case) - self.assertEqual(case_converter(value), expected) + self.assertConverter(case, value, expected) @parameterized.expand(_expand_values(VALUES_ACRONYM)) def test_acronyms(self, _, case, value, expected): @@ -461,9 +488,7 @@ def test_acronyms(self, _, case, value, expected): Test conversions from all cases to all cases that don't preserve capital/lower case letters (with acronym detection). """ - case_converter = getattr(case_conversion, case) - result = case_converter(value, acronyms=ACRONYMS) - self.assertEqual(result, expected) + self.assertConverter(case, value, expected, acronyms=ACRONYMS) @parameterized.expand(_expand_values(VALUES_ACRONYM_UNICODE)) def test_acronyms_unicode(self, _, case, value, expected): @@ -472,9 +497,7 @@ def test_acronyms_unicode(self, _, case, value, expected): capital/lower case letters (with acronym detection and unicode characters). """ - case_converter = getattr(case_conversion, case) - result = case_converter(value, acronyms=ACRONYMS_UNICODE) - self.assertEqual(result, expected) + self.assertConverter(case, value, expected, acronyms=ACRONYMS_UNICODE) @parameterized.expand( _expand_values_preserve(PRESERVE_VALUES_ACRONYM, VALUES_ACRONYM) @@ -484,9 +507,7 @@ def test_acronyms_preserve_case(self, _, case, value, expected): Test conversions from all cases to all cases that do preserve capital/lower case letters (with acronym detection). """ - case_converter = getattr(case_conversion, case) - result = case_converter(value, acronyms=ACRONYMS) - self.assertEqual(result, expected) + self.assertConverter(case, value, expected, acronyms=ACRONYMS) @parameterized.expand( _expand_values_preserve(PRESERVE_VALUES_ACRONYM_UNICODE, VALUES_ACRONYM_UNICODE) @@ -497,6 +518,4 @@ def test_acronyms_preserve_case_unicode(self, _, case, value, expected): capital/lower case letters (with acronym detection and unicode characters). """ - case_converter = getattr(case_conversion, case) - result = case_converter(value, acronyms=ACRONYMS_UNICODE) - self.assertEqual(result, expected) + self.assertConverter(case, value, expected, acronyms=ACRONYMS_UNICODE) From dec91a4f2f590985f75029438c03b3afff4b2a2a Mon Sep 17 00:00:00 2001 From: Alejandro Frias <3598338+AlejandroFrias@users.noreply.github.com> Date: Wed, 27 Aug 2025 17:26:01 -0500 Subject: [PATCH 4/4] add more aliases and fall back to simple acronym detection when there are remaining adjacent upper case letters --- README.md | 34 +++++++++++++++++----------------- case_conversion/__init__.py | 6 ++++++ case_conversion/acronym.py | 16 ++++++++++++---- case_conversion/converter.py | 6 ++++++ case_conversion/parser.py | 11 ++++++++++- tests/test_acronym.py | 12 ++++++++++-- tests/test_converter.py | 21 +++++++++++++++++++++ 7 files changed, 82 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 75c9fad..fa11604 100644 --- a/README.md +++ b/README.md @@ -15,18 +15,17 @@ This is a port of the Sublime Text 3 plugin [CaseConversion](https://github.com/ - Dependency free! - Supports Python 3.10+ - Every case conversion from/to you ever gonna need: - - `camelCase` - - `PascalCase` - - `snake_case` - - `dash-case` (aka `kebap-case`, `spinal-case` or `slug-case`) - - `CONST_CASE` (aka `SCREAMING_SNAKE_CASE`) - - `dot.case` - - `separate words` - - `slash/case` - - `backslash\\case` - - `Ada_Case` - - `Http-Header-Case` - - ` + - `camel` -> "camelCase" + - `pascal` / `mixed` -> "PascalCase" / "MixedCase" + - `snake` -> "snake_case" + - `snake` / `kebab` / `spinal` / `slug` -> "dash-case" / "kebab-case" / "spinal-case" / "slug-case" + - `const` / `screaming_snake` -> "CONST_CASE" / "SCREAMING_SNAKE_CASE" + - `dot` -> "dot.case" + - `separate_words` -> "separate words" + - `slash` -> "slash/case" + - `backslash` -> "backslash\case" + - `ada` -> "Ada_Case" + - `http_header` -> "Http-Header-Case" ## Usage @@ -72,14 +71,15 @@ For backwards compatibility and convenience, all converters are available as top 'foo-bar-string' ``` -To use acronym detection simply pass in a list of `acronyms` to detect as whole words. +Simple acronym detection comes included, by treating strings of capital letters as a single word instead of several single letter words. +Custom acronyms can be supplied when needing to separate them from each other. ```python >>> import case_conversion ->>> case_conversion.snake("fooBarHTTPError") -'foo_bar_h_t_t_p_error' # ewwww :( ->>> case_conversion.snake("fooBarHTTPError", acronyms=['HTTP']) -'foo_bar_http_error' # pretty :) +>>> case_conversion.snake("fooBADHTTPError") +'foo_badhttp_error' # we wanted BAD and HTTP to be separate! +>>> case_conversion.snake("fooBarHTTPError", acronyms=['BAD', 'HTTP']) +'foo_bad_http_error' # custom acronyms achieved! ``` Unicode is fully supported - even for acronyms. diff --git a/case_conversion/__init__.py b/case_conversion/__init__.py index 384ead2..87eb8fa 100644 --- a/case_conversion/__init__.py +++ b/case_conversion/__init__.py @@ -4,6 +4,9 @@ mixed, snake, dash, + kebab, + spinal, + slug, const, screaming_snake, dot, @@ -27,6 +30,9 @@ "mixed", "snake", "dash", + "kebab", + "spinal", + "slug", "const", "screaming_snake", "dot", diff --git a/case_conversion/acronym.py b/case_conversion/acronym.py index fa22688..597032a 100644 --- a/case_conversion/acronym.py +++ b/case_conversion/acronym.py @@ -37,13 +37,13 @@ def advanced_acronym_detection( """Detect acronyms by checking against a list of acronyms. Arguments: - s (int): Index of first letter in run + s (int): Index of first word in run i (int): Index of current word words (list of str): Segmented input string acronyms (list of str): List of acronyms Returns: - int: Index of last letter in run + int: Index of last word in run """ # Combine each letter into single string. words_to_join = words[s:i] @@ -68,8 +68,16 @@ def advanced_acronym_detection( not_range.remove(j) # Add remaining letters as ranges. - for nr in not_range: - range_list.append((nr, nr + 1)) + if not_range: + not_range = sorted(not_range) + start_nr = not_range[0] if not_range else -1 + prev_nr = start_nr - 1 + for nr in sorted(not_range): + if nr > prev_nr + 1: + range_list.append((start_nr, prev_nr + 1)) + start_nr = nr + prev_nr = nr + range_list.append((start_nr, prev_nr + 1)) # No ranges will overlap, so it's safe to sort by lower bound, # which sort() will do by default. diff --git a/case_conversion/converter.py b/case_conversion/converter.py index 28b9381..3db3fd3 100644 --- a/case_conversion/converter.py +++ b/case_conversion/converter.py @@ -119,6 +119,7 @@ def snake(self, text: str | None = None) -> str: return "" + @alias("kebab", "spinal", "slug") def dash(self, text: str | None = None) -> str: """Return text in dash-case style. @@ -553,6 +554,11 @@ def dash(text: str, acronyms: list[str] | None = None) -> str: return Converter(text=text, acronyms=acronyms).dash() +kebab = dash +spinal = dash +slug = dash + + def const(text: str, acronyms: list[str] | None = None) -> str: """Return text in CONST_CASE style. diff --git a/case_conversion/parser.py b/case_conversion/parser.py index 0d5d0aa..5f892d7 100644 --- a/case_conversion/parser.py +++ b/case_conversion/parser.py @@ -123,7 +123,7 @@ def parse_into_words( >>> [word.original_word for word in words] ['hello', 'HTML', 'World'] """ - words_with_sep = segment_string(string) + words_with_sep = segment_string(string.strip()) if acronyms: # Use advanced acronym detection with list @@ -142,6 +142,7 @@ def parse_into_words( s = None # Find runs of single upper-case letters. + word = None while i < len(words_with_sep): word = words_with_sep[i] if word is not None and is_upper_char(word): @@ -152,6 +153,14 @@ def parse_into_words( s = None i += 1 + if s is not None: + check_acronym(s, i, words_with_sep, acronyms) + + # Handle case where the entire string is all caps with no separators, + # but there are possibly acronyms to detect within it. + if len(words_with_sep) == 1: + check_acronym(0, 1, words_with_sep, acronyms) + # Separators are no longer needed, so they should be removed. words: list[str] = [w for w in words_with_sep if w is not None] diff --git a/tests/test_acronym.py b/tests/test_acronym.py index 26fd28b..b7198a5 100644 --- a/tests/test_acronym.py +++ b/tests/test_acronym.py @@ -6,6 +6,7 @@ normalize_acronyms, simple_acronym_detection, ) +from case_conversion.converter import snake @pytest.mark.parametrize( @@ -51,14 +52,21 @@ def test_simple_acronym_detection(s, i, words, expected): "s,i,words,acronyms,expected", ( (0, 1, ["FOO", "bar"], ("FOO",), 0), - (0, 1, ["FOO", "bar"], ("BAR",), 2), - (0, 1, ["FOFOO"], ("FO", "FOO"), 2), + (0, 1, ["FOO", "bar"], ("BAR",), 0), + (0, 1, ["FOFOO"], ("FOO", "FO"), 1), ), ) def test_advanced_acronym_detection(s, i, words, acronyms, expected): assert advanced_acronym_detection(s, i, words, acronyms) == expected +def test_advanced_acronym_detection_with_fallback_to_simple(): + assert snake("fooBARBAZError", acronyms=["BAR"]) == "foo_bar_baz_error" + assert snake("fooBARBAZError", acronyms=["BAZ"]) == "foo_bar_baz_error" + assert snake("fooBARBAZBAR", acronyms=["BAZ"]) == "foo_bar_baz_bar" + assert snake("BARBAZBAR", acronyms=["BAZ"]) == "bar_baz_bar" + + @pytest.mark.parametrize("acronyms", ("HT-TP", "NA SA", "SU.GAR")) def test_sanitize_acronyms_raises_on_invalid_acronyms(acronyms): with pytest.raises(InvalidAcronymError): diff --git a/tests/test_converter.py b/tests/test_converter.py index d85d1bb..ad211b4 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -14,6 +14,9 @@ "mixed", "snake", "dash", + "kebab", + "spinal", + "slug", "const", "screaming_snake", "dot", @@ -30,6 +33,9 @@ "mixed": "FooBarString", "snake": "foo_bar_string", "dash": "foo-bar-string", + "kebab": "foo-bar-string", + "spinal": "foo-bar-string", + "slug": "foo-bar-string", "const": "FOO_BAR_STRING", "screaming_snake": "FOO_BAR_STRING", "dot": "foo.bar.string", @@ -46,6 +52,9 @@ "mixed": "FóoBarString", "snake": "fóo_bar_string", "dash": "fóo-bar-string", + "kebab": "fóo-bar-string", + "spinal": "fóo-bar-string", + "slug": "fóo-bar-string", "const": "FÓO_BAR_STRING", "screaming_snake": "FÓO_BAR_STRING", "dot": "fóo.bar.string", @@ -62,6 +71,9 @@ "mixed": "Foo", "snake": "foo", "dash": "foo", + "kebab": "foo", + "spinal": "foo", + "slug": "foo", "const": "FOO", "screaming_snake": "FOO", "dot": "foo", @@ -78,6 +90,9 @@ "mixed": "Fóo", "snake": "fóo", "dash": "fóo", + "kebab": "fóo", + "spinal": "fóo", + "slug": "fóo", "const": "FÓO", "screaming_snake": "FÓO", "dot": "fóo", @@ -94,6 +109,9 @@ "mixed": "FooHTTPBarString", "snake": "foo_http_bar_string", "dash": "foo-http-bar-string", + "kebab": "foo-http-bar-string", + "spinal": "foo-http-bar-string", + "slug": "foo-http-bar-string", "const": "FOO_HTTP_BAR_STRING", "screaming_snake": "FOO_HTTP_BAR_STRING", "dot": "foo.http.bar.string", @@ -110,6 +128,9 @@ "mixed": "FooHÉÉPBarString", "snake": "foo_héép_bar_string", "dash": "foo-héép-bar-string", + "kebab": "foo-héép-bar-string", + "spinal": "foo-héép-bar-string", + "slug": "foo-héép-bar-string", "const": "FOO_HÉÉP_BAR_STRING", "screaming_snake": "FOO_HÉÉP_BAR_STRING", "dot": "foo.héép.bar.string",