nikkie-ftnextの日記

イベントレポートや読書メモを発信

キャメルケースをスネークケースに変換するにはどうする? Streamlitの実装に見つけた正規表現を使う例

はじめに

無惨さま、final付けてこ! nikkieです

文字列操作の小ネタです。
Streamlitの実装を見ていたところ、興味深い実装を見つけました。

目次

キャメルケースをスネークケースに変換する実装 in Streamlit

キャメルケースには2種類あります。

  • UpperCamelCase(単語の始まりは全部大文字で統一)
  • lowerCamelCase(先頭の単語だけ小文字始まり、他の単語は大文字始まり)

どちらの場合もスネークケースに変換することを考えます。

  • upper_camel_case
  • lower_camel_case

こちらを行うStreamlitの実装は以下です:
https://github.com/streamlit/streamlit/blob/1.22.0/lib/streamlit/case_converters.py#L47-L57

def to_snake_case(camel_case_str):
    s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", camel_case_str)
    return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()

動作環境

  • Python 3.10.9
  • Streamlit, version 1.22.0
>>> from streamlit.case_converters import to_snake_case
>>> to_snake_case("UpperCamelCase")
'upper_camel_case'
>>> to_snake_case("lowerCamelCase")
'lower_camel_case'

2つの正規表現単語の区切りにアンダースコアを挿入した後、小文字にしているようです。
正規表現を使った実装、何をやっているのか読み解いていきましょう。

正規表現を使って、キャメルケースをスネークケースに変換する

re.subの第2引数に使われている\1\2後方参照と呼ばれますね1
https://docs.python.org/ja/3/library/re.html#re.sub

\6 のような後方参照は、パターンのグループ 6 がマッチした部分文字列で置換されます。

re.sub("(.)([A-Z][a-z]+)", r"\1_\2", camel_case_str)の場合だと、第1引数の正規表現(キャプチャの丸カッコが2つある)で見つかったグループ1とグループ2の間に_を入れます

ではグループ1とグループ2はどんなパターンが該当するのでしょうか。
re.searchを使って確認していきましょう。
https://docs.python.org/ja/3/library/re.html#re.search

"(.)([A-Z][a-z]+)"に該当するのは?

>>> import re
>>> re.search("(.)([A-Z][a-z]+)", "UpperCamelCase")
<re.Match object; span=(4, 10), match='rCamel'>
>>> m = _
>>> m.group(1)  # \1 を確認
'r'
>>> m.group(2)  # \2 を確認
'Camel'
  • 正規表現"(.)"は任意の1文字ですね
  • 正規表現"([A-Z][a-z]+)"
    • 大文字で始まり
    • 小文字が1つ以上続く部分です
    • つまり、キャメルケースの1単語ということですね

というわけで、re.subのコードでは、先頭の単語と次の単語の間にアンダースコアが入ります。

>>> re.sub("(.)([A-Z][a-z]+)", r"\1_\2", "UpperCamelCase")
'Upper_CamelCase'
>>> re.sub("(.)([A-Z][a-z]+)", r"\1_\2", "lowerCamelCase")
'lower_CamelCase'

"([a-z0-9])([A-Z])"に該当するのは?

>>> re.search("([a-z0-9])([A-Z])", "Upper_CamelCase")
<re.Match object; span=(10, 12), match='lC'>
>>> m = _
>>> m.group(1)  # \1
'l'
>>> m.group(2)  # \2
'C'
  • 正規表現"([a-z0-9])"小文字か数字の1文字です
  • 正規表現"([A-Z])"大文字1文字です
  • キャメルケースの文字列では、前の単語が終わって次の単語が(大文字で)始まる区切りですね

re.subのコードでは、単語の間にアンダースコアが入ります。

>>> re.sub("([a-z0-9])([A-Z])", r"\1_\2", "Upper_CamelCase")
'Upper_Camel_Case'
>>> re.sub("([a-z0-9])([A-Z])", r"\1_\2", "lower_CamelCase")
'lower_Camel_Case'

キャメルケースが長くなった場合、すべての単語区切りが見つかります。
https://docs.python.org/ja/3/library/re.html#re.findall

>>> re.findall("([a-z0-9])([A-Z])", "Upper_CamelCaseSpam")
[('l', 'C'), ('e', 'S')]  # (Came)lとC(ase)、(Cas)eとS(pam)

長くなった場合でもre.subはパターンに該当する部分=すべての単語区切りマッチし、アンダースコアを入れるように置き換えます。

>>> re.sub("([a-z0-9])([A-Z])", r"\1_\2", "Upper_CamelCaseSpam")
'Upper_Camel_Case_Spam'

こうしてすべての単語がアンダースコアで区切られました。
あとは小文字にしたらスネークケースです!

Streamlitのconfigの値で確認

streamlit config showでconfigを一覧できます。
さまざまな長さのキャメルケースを拾い集めました

  • port (server)
  • gatherUsageStats (browser)
  • enforceSerializableSessionState (runner)
  • showPyplotGlobalUse (deprecation)

ここまでの正規表現を元に自前で関数を実装し、それに渡してみましょう。

>>> def my_snake_case(camel_case_str):
...   s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", camel_case_str)
...   return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
>>> my_snake_case("port")
'port'
>>> my_snake_case("gatherUsageStats")
'gather_usage_stats'
>>> my_snake_case("enforceSerializableSessionState")
'enforce_serializable_session_state'
>>> my_snake_case("showPyplotGlobalUse")
'show_pyplot_global_use'

キャメルケースをスネークケースに変換するStreamlitの実装、完全に理解した!

終わりに

キャメルケースをスネークケースに変換する正規表現を使った実装を読み解きました。
キャメルケースの文字列を観察すると、単語の切れ目が正規表現で見つけられるのですね。
そこに対して、後方参照を使ってアンダースコアを挿入していく!

>>> my_snake_case("DreamWithYou")
'dream_with_you'

P.S. その1 もっと簡潔な実装が見つかったかも

確認不足感は否めないのですが、2つの正規表現のうち後者だけでもスネークケースにできているように思われます。

>>> re.sub("([a-z0-9])([A-Z])", r"\1_\2", "port").lower()
'port'
>>> re.sub("([a-z0-9])([A-Z])", r"\1_\2", "gatherUsageStats").lower()
'gather_usage_stats'
>>> re.sub("([a-z0-9])([A-Z])", r"\1_\2", "enforceSerializableSessionState").lower()
'enforce_serializable_session_state'
>>> re.sub("([a-z0-9])([A-Z])", r"\1_\2", "showPyplotGlobalUse").lower()
'show_pyplot_global_use'

>>> re.sub("([a-z0-9])([A-Z])", r"\1_\2", "UpperCamelCase").lower()
'upper_camel_case'

プルリクチャンスかな? なにか見落としているかな?

P.S. その2 pyhumpsのdecamelize

pyhumps 3.8.0

>>> import humps
>>> humps.decamelize("port")
'port'
>>> humps.decamelize("gatherUsageStats")
'gather_usage_stats'
>>> humps.decamelize("enforceSerializableSessionState")
'enforce_serializable_session_state'
>>> humps.decamelize("showPyplotGlobalUse")
'show_pyplot_global_use'

>>> humps.decamelize("UpperCamelCase")
'upper_camel_case'

こちらの実装は正規表現を使っていますが、Streamlitとは同じではないと分かりました。
https://github.com/nficano/humps/blob/v3.8.0/humps/main.py#L96-L114
読み解きはこれからです〜


  1. 過去に取り上げたエントリがこちら: