That one line turns HTTPServer into http_server and getHTTP2Client into get_http2_client. It makes two passes. The inner re.sub splits a run of capitals before its last capital when a lowercase letter follows, so HTTPServer becomes HTTP_Server. The outer one puts an underscore between a lowercase letter or digit and the capital after it. Then everything is lowercased.
import re
def to_snake(name: str) -> str:
name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
name = re.sub(r"([a-z\d])([A-Z])", r"\1_\2", name)
return name.lower()
for name in ["HTTPServer", "getHTTPResponseCode", "userID", "XMLHttpRequest", "base64Encode", "getHTTP2Client"]:
print(f"{name:20} {to_snake(name)}")
HTTPServer http_server
getHTTPResponseCode get_http_response_code
userID user_id
XMLHttpRequest xml_http_request
base64Encode base64_encode
getHTTP2Client get_http2_client
The tool below converts the same six names, one per line, and its snake_case row matches.
Why one regex isn’t enough
The version most people write first puts an underscore before every capital:
re.sub(r"(?<!^)(?=[A-Z])", "_", "HTTPServer").lower() # 'h_t_t_p_server'
re.sub(r"([A-Z])", r"_\1", "HTTPServer").lower() # '_h_t_t_p_server'
re.sub(r"(?<!^)(?=[A-Z])", "_", "userID").lower() # 'user_i_d'
Acronyms fall apart, and without the lookbehind a leading capital adds a leading underscore. Splitting only where a lowercase letter meets a capital, ([a-z])([A-Z]), goes wrong the other way: HTTPServer becomes httpserver. The first pattern of the pair handles the capital run, the second handles ordinary word breaks.
Digits
([a-z\d]) lets a digit end a word, so base64Encode keeps base64 together. A digit never starts a word here: parseHTTPResponse2xx gives parse_http_response2xx, while the tool below treats 2xx as a word of its own and writes parse_http_response_2xx. Choose one rule and use it everywhere, or names won’t match across systems.
inflection and non-ASCII letters
The inflection package, a port of the Rails inflector, uses the same two patterns and also turns - into _:
import inflection
inflection.underscore("HTTPServer") # 'http_server'
inflection.underscore("getHTTP2Client") # 'get_http2_client'
[A-Z] and [a-z] match ASCII only, so caféBar comes out as cafébar. The standard re module has no Unicode letter classes, but the third-party regex module does: written as (\p{Lu}+)(\p{Lu}\p{Ll}) and ([\p{Ll}\d])(\p{Lu}), the same two patterns turn caféBar into café_bar.
The reverse: snake_case to camelCase
def to_camel(name: str) -> str:
first, *rest = name.split("_")
return first + "".join(word.capitalize() for word in rest)
to_camel("get_http2_client") # 'getHttp2Client'
inflection.camelize("http_server", False) # 'httpServer'
The round trip loses acronyms: HTTPServer goes to http_server and comes back as httpServer. If an API needs the original names, keep a mapping instead of converting back.