Repository: nficano/humps

Issue:
Support for Kebab case?
**Is your feature request related to a problem? Please describe.**
#180 #151 

**Describe the solution you'd like**
Discussion: Should we add support for Kebab case? 
- [x] kebabize
- [x] dekebabize
- [x] is_kebabcase
- [x] type hints
- [ ] test cases

**Describe alternatives you've considered**
-

**Additional context**
-


Discussion:

Patch:
diff --git a/README.md b/README.md
index 7708262..c0c2004 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,7 @@ import humps
 humps.camelize("jack_in_the_box")  # jackInTheBox
 humps.decamelize("rubyTuesdays")  # ruby_tuesdays
 humps.pascalize("red_robin")  # RedRobin
+humps.kebabize("white_castle")  # white-castle
 ```
 
 ### Converting dictionary keys
@@ -58,6 +59,9 @@ humps.decamelize(array) # [{"attr_one": "foo"}, {"attr_one": "bar"}]
 array = [{"attr_one": "foo"}, {"attr_one": "bar"}]
 humps.camelize(array)  # [{"attrOne": "foo"}, {"attrOne": "bar"}]
 
+array = [{'attr_one': 'foo'}, {'attr_one': 'bar'}]
+humps.kebabize(array)  # [{'attr-one': 'foo'}, {'attr-one': 'bar'}]
+
 array = [{"attr_one": "foo"}, {"attr_one": "bar"}]
 humps.pascalize(array)  # [{"AttrOne": "foo"}, {"AttrOne": "bar"}]
 ```
@@ -70,8 +74,10 @@ import humps
 humps.is_camelcase("illWearYourGranddadsClothes")  # True
 humps.is_pascalcase("ILookIncredible")  # True
 humps.is_snakecase("im_in_this_big_ass_coat")  # True
+humps.is_kebabcase('from-that-thrift-shop')  # True
 humps.is_camelcase("from_that_thrift_shop")  # False
 humps.is_snakecase("downTheRoad")  # False
+humps.is_kebabcase('from_that_thrift_shop')  # False
 
 # what about abbrevations, acronyms, and initialisms? No problem!
 humps.decamelize("APIResponse")  # api_response
diff --git a/docs/index.rst b/docs/index.rst
index 2ef30f7..010b085 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -36,16 +36,19 @@ Release v\ |version|. (:ref:`Installation <install>`)
     >>> import humps
     >>> humps.decamelize('illWearYourGranddadsClothes')   # 'ill_wear_your_granddads_clothes'
     >>> humps.camelize('i_look_incredible')               # 'iLookIncredible'
+    >>> humps.kebabize('i_look_incredible')               # 'i-look-incredible'
     >>> humps.pascalize('im_in_this_big_ass_coat')        # 'ImInThisBigAssCoat'
     >>> humps.decamelize('FROMThatThriftShop')            # 'from_that_thrift_shop'
     >>> humps.decamelize([{'downTheRoad': True}])         # [{'down_the_road': True}]
+    >>> humps.dekebabize('FROM-That-Thrift-Shop')            # 'FROM_That_Thrift_Shop'
 
 Features
 --------
 
-- Convert from ``snake_case`` to ``camelCase`` and ``PascalCase``
+- Convert from ``snake_case`` to ``camelCase`` and ``PascalCase`` and ``kebab-case``
 - Convert from ``camelCase`` to ``snake_case`` and ``PascalCase``
 - Convert from ``PascalCase`` to  ``snake_case`` and ``camelCase``
+- Convert from ``kebab-case`` to ``snake_case``
 - Supports recursively converting ``dict`` keys
 - Supports recursively converting lists of dictionaries
 - Gracefully handles abbrevations, acronyms, and initialisms
diff --git a/humps/__init__.py b/humps/__init__.py
index 66ae0f1..d613b7b 100644
--- a/humps/__init__.py
+++ b/humps/__init__.py
@@ -12,9 +12,12 @@ __copyright__ = "Copyright 2019 Nick Ficano"
 from humps.main import (
     camelize,
     decamelize,
+    kebabize,
+    dekebabize,
     depascalize,
     is_camelcase,
     is_pascalcase,
+    is_kebabcase,
     is_snakecase,
     pascalize,
 )
diff --git a/humps/main.py b/humps/main.py
index 08b2cae..ee60b02 100644
--- a/humps/main.py
+++ b/humps/main.py
@@ -65,6 +65,29 @@ def camelize(str_or_iter):
     return UNDERSCORE_RE.sub(lambda m: m.group(0)[-1].upper(), s)
 
 
+def kebabize(str_or_iter):
+    """
+    Convert a string, dict, or list of dicts to kebab case.
+    :param str_or_iter:
+        A string or iterable.
+    :type str_or_iter: Union[list, dict, str]
+    :rtype: Union[list, dict, str]
+    :returns:
+        kebabized string, dictionary, or list of dictionaries.
+    """
+    if isinstance(str_or_iter, (list, Mapping)):
+        return _process_keys(str_or_iter, kebabize)
+
+    s = str(_is_none(str_or_iter))
+    if s.isnumeric():
+        return str_or_iter
+
+    if not (s.isupper()) and (is_camelcase(s) or is_pascalcase(s)):
+        return _separate_words(string=_fix_abbreviations(s), separator="-").lower()
+
+    return UNDERSCORE_RE.sub(lambda m: "-" + m.group(0)[-1], s)
+
+
 def decamelize(str_or_iter):
     """
     Convert a string, dict, or list of dicts to snake case.
@@ -99,6 +122,26 @@ def depascalize(str_or_iter):
     return decamelize(str_or_iter)
 
 
+def dekebabize(str_or_iter):
+    """
+    Convert a string, dict, or list of dicts to snake case.
+    :param str_or_iter:
+        A string or iterable.
+    :type str_or_iter: Union[list, dict, str]
+    :rtype: Union[list, dict, str]
+    :returns:
+        snake cased string, dictionary, or list of dictionaries.
+    """
+    if isinstance(str_or_iter, (list, Mapping)):
+        return _process_keys(str_or_iter, dekebabize)
+
+    s = str(_is_none(str_or_iter))
+    if s.isnumeric():
+        return str_or_iter
+
+    return s.replace("-", "_")
+
+
 def is_camelcase(str_or_iter):
     """
     Determine if a string, dict, or list of dicts is camel case.
@@ -126,6 +169,19 @@ def is_pascalcase(str_or_iter):
     return str_or_iter == pascalize(str_or_iter)
 
 
+def is_kebabcase(str_or_iter):
+    """
+    Determine if a string, dict, or list of dicts is camel case.
+    :param str_or_iter:
+        A string or iterable.
+    :type str_or_iter: Union[list, dict, str]
+    :rtype: bool
+    :returns:
+        True/False whether string or iterable is camel case
+    """
+    return str_or_iter == kebabize(str_or_iter)
+
+
 def is_snakecase(str_or_iter):
     """
     Determine if a string, dict, or list of dicts is snake case.
@@ -137,6 +193,9 @@ def is_snakecase(str_or_iter):
     :returns:
         True/False whether string or iterable is snake case
     """
+    if is_kebabcase(str_or_iter) and not (is_camelcase(str_or_iter)):
+        return False
+
     return str_or_iter == decamelize(str_or_iter)
 
 
diff --git a/humps/main.pyi b/humps/main.pyi
index 5d56db0..105bbb8 100644
--- a/humps/main.pyi
+++ b/humps/main.pyi
@@ -6,8 +6,11 @@ StrOrIter = TypeVar('StrOrIter', bound=Union[str, Mapping, list])
 
 def pascalize(str_or_iter: StrOrIter) -> StrOrIter: ...
 def camelize(str_or_iter: StrOrIter) -> StrOrIter: ...
+def kebabize(str_or_iter: StrOrIter) -> StrOrIter: ...
 def decamelize(str_or_iter: StrOrIter) -> StrOrIter: ...
 def depascalize(str_or_iter: StrOrIter) -> StrOrIter: ...
+def dekebabize(str_or_iter: StrOrIter) -> StrOrIter: ...
 def is_camelcase(str_or_iter: StrOrIter) -> bool: ...
 def is_pascalcase(str_or_iter: StrOrIter) -> bool: ...
+def is_kebabcase(str_or_iter: StrOrIter) -> bool: ...
 def is_snakecase(str_or_iter: StrOrIter) -> bool: ...


Test patch:
diff --git a/tests/test_dekebabize.py b/tests/test_dekebabize.py
new file mode 100644
index 0000000..012bd8b
--- /dev/null
+++ b/tests/test_dekebabize.py
@@ -0,0 +1,103 @@
+"""
+Test dekabization.
+"""
+import pytest
+
+import humps
+
+
+@pytest.mark.parametrize(
+    "input_str, expected_output",
+    [
+        ("symbol", "symbol"),
+        ("last-price", "last_price"),
+        ("Change-Pct", "Change_Pct"),
+        ("implied-Volatility", "implied_Volatility"),
+        ("_symbol", "_symbol"),
+        ("change-pct_", "change_pct_"),
+        ("_last-price__", "_last_price__"),
+        ("__implied-volatility_", "__implied_volatility_"),
+        ("API", "API"),
+        ("_API_", "_API_"),
+        ("__API__", "__API__"),
+        ("API-Response", "API_Response"),
+        ("_API-Response_", "_API_Response_"),
+        ("__API-Response__", "__API_Response__"),
+    ],
+)
+def test_dekebabize(input_str, expected_output):
+    """
+    :param input_str: String that will be transformed.
+    :param expected_output: The expected transformation.
+    """
+    output = humps.dekebabize(input_str)
+    assert output == expected_output, "{} != {}".format(
+        output, expected_output
+    )
+
+
+def test_dekebabize_dict_list():
+    actual = humps.dekebabize(
+        [
+            {
+                "symbol": "AAL",
+                "last-price": 31.78,
+                "Change-Pct": 2.8146,
+                "implied-Volatility": 0.482,
+            },
+            {
+                "symbol": "LBTYA",
+                "last-price": 25.95,
+                "Change-Pct": 2.6503,
+                "implied-Volatility": 0.7287,
+            },
+            {
+                "_symbol": "LBTYK",
+                "Change-Pct_": 2.5827,
+                "_last-price__": 25.42,
+                "__implied-Volatility_": 0.4454,
+            },
+            {
+                "API": "test_upper",
+                "_API_": "test_upper",
+                "__API__": "test_upper",
+                "API-Response": "test_acronym",
+                "_API-Response_": "test_acronym",
+                "__API-Response__": "test_acronym",
+                "ruby_tuesdays": "ruby_tuesdays",
+                "_item-ID": "_item_id",
+            },
+        ]
+    )
+    expected = [
+        {
+            "symbol": "AAL",
+            "last_price": 31.78,
+            "Change_Pct": 2.8146,
+            "implied_Volatility": 0.482,
+        },
+        {
+            "symbol": "LBTYA",
+            "last_price": 25.95,
+            "Change_Pct": 2.6503,
+            "implied_Volatility": 0.7287,
+        },
+        {
+            "_symbol": "LBTYK",
+            "Change_Pct_": 2.5827,
+            "_last_price__": 25.42,
+            "__implied_Volatility_": 0.4454,
+        },
+        {
+            "API": "test_upper",
+            "_API_": "test_upper",
+            "__API__": "test_upper",
+            "API_Response": "test_acronym",
+            "_API_Response_": "test_acronym",
+            "__API_Response__": "test_acronym",
+            "ruby_tuesdays": "ruby_tuesdays",
+            "_item_ID": "_item_id",
+        },
+    ]
+
+    assert actual == expected, "{} != {}".format(actual, expected)
diff --git a/tests/test_humps.py b/tests/test_humps.py
index 8083c3d..144f933 100644
--- a/tests/test_humps.py
+++ b/tests/test_humps.py
@@ -8,6 +8,8 @@ def test_converting_strings():
     assert humps.decamelize("rubyTuesdays") == "ruby_tuesdays"
     assert humps.depascalize("UnosPizza") == "unos_pizza"
     assert humps.pascalize("red_robin") == "RedRobin"
+    assert humps.kebabize("white_castle") == "white-castle"
+    assert humps.dekebabize("taco-bell") == "taco_bell"
 
 
 @pytest.mark.parametrize(
@@ -35,29 +37,40 @@ def test_conditionals():
     assert humps.is_pascalcase("RedRobin")
     assert humps.is_snakecase("RedRobin") is False
     assert humps.is_camelcase("RedRobin") is False
+    assert humps.is_kebabcase("RedRobin") is False
 
     assert humps.is_snakecase("ruby_tuesdays")
     assert humps.is_camelcase("ruby_tuesdays") is False
-    assert humps.is_pascalcase("jackInTheBox") is False
+    assert humps.is_pascalcase("ruby_tuesdays") is False
+    assert humps.is_kebabcase("ruby_tuesdays") is False
 
     assert humps.is_camelcase("jackInTheBox")
     assert humps.is_snakecase("jackInTheBox") is False
     assert humps.is_pascalcase("jackInTheBox") is False
+    assert humps.is_kebabcase("jackInTheBox") is False
+
+    assert humps.is_kebabcase("white-castle")
+    assert humps.is_snakecase("white-castle") is False
+    assert humps.is_camelcase("white-castle") is False
+    assert humps.is_pascalcase("white-castle") is False
 
     assert humps.is_camelcase("API")
     assert humps.is_pascalcase("API")
     assert humps.is_snakecase("API")
+    assert humps.is_kebabcase("API")
 
     # Fixed issue #128
     assert humps.is_snakecase("whatever_10")
     assert humps.is_camelcase("whatever_10") is False
     assert humps.is_pascalcase("whatever_10") is False
+    assert humps.is_kebabcase("whatever_10") is False
 
 
 def test_numeric():
     assert humps.camelize(1234) == 1234
     assert humps.decamelize(123) == 123
     assert humps.pascalize(123) == 123
+    assert humps.kebabize(123) == 123
 
 
 def test_upper():
@@ -65,6 +78,8 @@ def test_upper():
     assert humps.decamelize("API") == "API"
     assert humps.pascalize("API") == "API"
     assert humps.depascalize("API") == "API"
+    assert humps.kebabize("API") == "API"
+    assert humps.dekebabize("API") == "API"
 
 
 def test_pascalize():
diff --git a/tests/test_kebabize.py b/tests/test_kebabize.py
new file mode 100644
index 0000000..27b8712
--- /dev/null
+++ b/tests/test_kebabize.py
@@ -0,0 +1,102 @@
+"""
+Test kebabization.
+"""
+import pytest
+
+import humps
+
+
+@pytest.mark.parametrize(
+    "input_str, expected_output",
+    [
+        ("fallback_url", "fallback-url"),
+        ("scrubber_media_url", "scrubber-media-url"),
+        ("dash_url", "dash-url"),
+        ("_fallback_url", "_fallback-url"),
+        ("__scrubber_media___url_", "__scrubber-media-url_"),
+        ("_url__", "_url__"),
+        ("API", "API"),
+        ("_API_", "_API_"),
+        ("__API__", "__API__"),
+        ("API_Response", "API-Response"),
+        ("_API_Response_", "_API-Response_"),
+        ("__API_Response__", "__API-Response__"),
+    ],
+)
+def test_kebabize(input_str, expected_output):
+    """
+    :param input_str: String that will be transformed.
+    :param expected_output: The expected transformation.
+    """
+    output = humps.kebabize(input_str)
+    assert output == expected_output, "{} != {}".format(
+        output, expected_output
+    )
+
+
+def test_kebabize_dict_list():
+    actual = humps.kebabize(
+        {
+            "videos": [
+                {
+                    "fallback_url": "https://media.io/video",
+                    "scrubber_Media_Url": "https://media.io/video",
+                    "dash_Url": "https://media.io/video",
+                }
+            ],
+            "images": [
+                {
+                    "fallback_url": "https://media.io/image",
+                    "scrubber_Media_Url": "https://media.io/image",
+                    "url": "https://media.io/image",
+                }
+            ],
+            "other": [
+                {
+                    "_fallback_url": "https://media.io/image",
+                    "__scrubber_Media___Url_": "https://media.io/image",
+                    "_url__": "https://media.io/image",
+                },
+                {
+                    "API": "test_upper",
+                    "_API_": "test_upper",
+                    "__API__": "test_upper",
+                    "APIResponse": "test_acronym",
+                    "_APIResponse_": "test_acronym",
+                    "__APIResponse__": "test_acronym",
+                },
+            ],
+        }
+    )
+    expected = {
+        "videos": [
+            {
+                "fallback-url": "https://media.io/video",
+                "scrubber-Media-Url": "https://media.io/video",
+                "dash-Url": "https://media.io/video",
+            }
+        ],
+        "images": [
+            {
+                "fallback-url": "https://media.io/image",
+                "scrubber-Media-Url": "https://media.io/image",
+                "url": "https://media.io/image",
+            }
+        ],
+        "other": [
+            {
+                "_fallback-url": "https://media.io/image",
+                "__scrubber-Media-Url_": "https://media.io/image",
+                "_url__": "https://media.io/image",
+            },
+            {
+                "API": "test_upper",
+                "_API_": "test_upper",
+                "__API__": "test_upper",
+                "api-response": "test_acronym",
+                "_api-response_": "test_acronym",
+                "__api-response__": "test_acronym",
+            },
+        ],
+    }
+    assert actual == expected