Skip to content

Remove snake_case -> camelCase prop conversion #1263

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/source/about/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Unreleased
- :pull:`1113` - Renamed the ``use_location`` hook's ``search`` attribute to ``query_string``.
- :pull:`1113` - Renamed the ``use_location`` hook's ``pathname`` attribute to ``path``.
- :pull:`1113` - Renamed ``reactpy.config.REACTPY_DEBUG_MODE`` to ``reactpy.config.REACTPY_DEBUG``.
- :pull:`1263` - ReactPy no longer auto-converts ``snake_case`` props to ``camelCase``. It is now the responsibility of the user to ensure that props are in the correct format.

**Removed**

Expand Down
11 changes: 7 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ authors = [
]
requires-python = ">=3.9"
classifiers = [
"Development Status :: 4 - Beta",
"Development Status :: 5 - Production/Stable",
"Programming Language :: Python",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
Expand Down Expand Up @@ -60,6 +61,9 @@ license-files = { paths = ["LICENSE"] }
[tool.hatch.envs.default]
installer = "uv"

[project.scripts]
reactpy = "reactpy._console.cli:entry_point"

[[tool.hatch.build.hooks.build-scripts.scripts]]
# Note: `hatch` can't be called within `build-scripts` when installing packages in editable mode, so we have to write the commands long-form
commands = [
Expand Down Expand Up @@ -162,8 +166,6 @@ extra-dependencies = [
"mypy==1.8",
"types-toml",
"types-click",
"types-tornado",
"types-flask",
"types-requests",
]

Expand Down Expand Up @@ -194,6 +196,7 @@ test = [
]
build = [
'hatch run "src/build_scripts/clean_js_dir.py"',
'bun install --cwd "src/js"',
'hatch run javascript:build_event_to_object',
'hatch run javascript:build_client',
'hatch run javascript:build_app',
Expand Down
2 changes: 1 addition & 1 deletion src/js/packages/@reactpy/client/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@reactpy/client",
"version": "0.3.2",
"version": "1.0.0",
"description": "A client for ReactPy implemented in React",
"author": "Ryan Morshead",
"license": "MIT",
Expand Down
33 changes: 1 addition & 32 deletions src/js/packages/@reactpy/client/src/vdom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,7 @@ export function createAttributes(
createEventHandler(client, name, handler),
),
),
// Convert snake_case to camelCase names
}).map(normalizeAttribute),
}),
);
}

Expand Down Expand Up @@ -182,33 +181,3 @@ function createEventHandler(
},
];
}

function normalizeAttribute([key, value]: [string, any]): [string, any] {
let normKey = key;
let normValue = value;

if (key === "style" && typeof value === "object") {
normValue = Object.fromEntries(
Object.entries(value).map(([k, v]) => [snakeToCamel(k), v]),
);
} else if (
key.startsWith("data_") ||
key.startsWith("aria_") ||
DASHED_HTML_ATTRS.includes(key)
) {
normKey = key.split("_").join("-");
} else {
normKey = snakeToCamel(key);
}
return [normKey, normValue];
}

function snakeToCamel(str: string): string {
return str.replace(/([_][a-z])/g, (group) =>
group.toUpperCase().replace("_", ""),
);
}

// see list of HTML attributes with dashes in them:
// https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#attribute_list
const DASHED_HTML_ATTRS = ["accept_charset", "http_equiv"];
19 changes: 0 additions & 19 deletions src/reactpy/__main__.py

This file was deleted.

19 changes: 19 additions & 0 deletions src/reactpy/_console/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Entry point for the ReactPy CLI."""

import click

import reactpy
from reactpy._console.rewrite_props import rewrite_props


@click.group()
@click.version_option(version=reactpy.__version__, prog_name=reactpy.__name__)
def entry_point() -> None:
pass


entry_point.add_command(rewrite_props)


if __name__ == "__main__":
entry_point()
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

import ast
import re
from copy import copy
from keyword import kwlist
from pathlib import Path
Expand All @@ -15,59 +14,80 @@
rewrite_changed_nodes,
)

CAMEL_CASE_SUB_PATTERN = re.compile(r"(?<!^)(?=[A-Z])")


@click.command()
@click.argument("paths", nargs=-1, type=click.Path(exists=True))
def rewrite_camel_case_props(paths: list[str]) -> None:
"""Rewrite camelCase props to snake_case"""

def rewrite_props(paths: list[str]) -> None:
"""Rewrite snake_case props to camelCase within <PATHS>."""
for p in map(Path, paths):
# Process each file or recursively process each Python file in directories
for f in [p] if p.is_file() else p.rglob("*.py"):
result = generate_rewrite(file=f, source=f.read_text(encoding="utf-8"))
if result is not None:
f.write_text(result)


def generate_rewrite(file: Path, source: str) -> str | None:
tree = ast.parse(source)
"""Generate the rewritten source code if changes are detected"""
tree = ast.parse(source) # Parse the source code into an AST

changed = find_nodes_to_change(tree)
changed = find_nodes_to_change(tree) # Find nodes that need to be changed
if not changed:
return None
return None # Return None if no changes are needed

new = rewrite_changed_nodes(file, source, tree, changed)
new = rewrite_changed_nodes(
file, source, tree, changed
) # Rewrite the changed nodes
return new


def find_nodes_to_change(tree: ast.AST) -> list[ChangedNode]:
"""Find nodes in the AST that need to be changed"""
changed: list[ChangedNode] = []
for el_info in find_element_constructor_usages(tree):
# Check if the props need to be rewritten
if _rewrite_props(el_info.props, _construct_prop_item):
# Add the changed node to the list
changed.append(ChangedNode(el_info.call, el_info.parents))
return changed


def conv_attr_name(name: str) -> str:
new_name = CAMEL_CASE_SUB_PATTERN.sub("_", name).lower()
return f"{new_name}_" if new_name in kwlist else new_name
"""Convert snake_case attribute name to camelCase"""
# Return early if the value is a Python keyword
if name in kwlist:
return name

# Return early if the value is not snake_case
if "_" not in name:
return name

# Split the string by underscores
components = name.split("_")

# Capitalize the first letter of each component except the first one
# and join them together
return components[0] + "".join(x.title() for x in components[1:])


def _construct_prop_item(key: str, value: ast.expr) -> tuple[str, ast.expr]:
"""Construct a new prop item with the converted key and possibly modified value"""
if key == "style" and isinstance(value, (ast.Dict, ast.Call)):
# Create a copy of the value to avoid modifying the original
new_value = copy(value)
if _rewrite_props(
new_value,
lambda k, v: (
(k, v)
# avoid infinite recursion
# Avoid infinite recursion
if k == "style"
else _construct_prop_item(k, v)
),
):
# Update the value if changes were made
value = new_value
else:
# Convert the key to camelCase
key = conv_attr_name(key)
return key, value

Expand All @@ -76,12 +96,15 @@ def _rewrite_props(
props_node: ast.Dict | ast.Call,
constructor: Callable[[str, ast.expr], tuple[str, ast.expr]],
) -> bool:
"""Rewrite the props in the given AST node using the provided constructor"""
did_change = False
if isinstance(props_node, ast.Dict):
did_change = False
keys: list[ast.expr | None] = []
values: list[ast.expr] = []
# Iterate over the keys and values in the dictionary
for k, v in zip(props_node.keys, props_node.values):
if isinstance(k, ast.Constant) and isinstance(k.value, str):
# Construct the new key and value
k_value, new_v = constructor(k.value, v)
if k_value != k.value or new_v is not v:
did_change = True
Expand All @@ -90,20 +113,22 @@ def _rewrite_props(
keys.append(k)
values.append(v)
if not did_change:
return False
return False # Return False if no changes were made
props_node.keys = keys
props_node.values = values
else:
did_change = False
keywords: list[ast.keyword] = []
# Iterate over the keywords in the call
for kw in props_node.keywords:
if kw.arg is not None:
# Construct the new keyword argument and value
kw_arg, kw_value = constructor(kw.arg, kw.value)
if kw_arg != kw.arg or kw_value is not kw.value:
did_change = True
kw = ast.keyword(arg=kw_arg, value=kw_value)
keywords.append(kw)
if not did_change:
return False
return False # Return False if no changes were made
props_node.keywords = keywords
return True
5 changes: 4 additions & 1 deletion src/reactpy/testing/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from reactpy.config import REACTPY_TESTS_DEFAULT_TIMEOUT
from reactpy.core.component import component
from reactpy.core.hooks import use_callback, use_effect, use_state
from reactpy.testing.common import GITHUB_ACTIONS
from reactpy.testing.logs import (
LogAssertionError,
capture_reactpy_logs,
Expand Down Expand Up @@ -138,7 +139,9 @@ async def __aexit__(
msg = "Unexpected logged exception"
raise LogAssertionError(msg) from logged_errors[0]

await asyncio.wait_for(self.webserver.shutdown(), timeout=60)
await asyncio.wait_for(
self.webserver.shutdown(), timeout=60 if GITHUB_ACTIONS else 5
)

async def restart(self) -> None:
"""Restart the server"""
Expand Down
9 changes: 9 additions & 0 deletions src/reactpy/testing/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import inspect
import os
import shutil
import time
from collections.abc import Awaitable
Expand All @@ -28,6 +29,14 @@ def clear_reactpy_web_modules_dir() -> None:


_DEFAULT_POLL_DELAY = 0.1
GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") in {
"y",
"yes",
"t",
"true",
"on",
"1",
}


class poll(Generic[_R]): # noqa: N801
Expand Down
2 changes: 1 addition & 1 deletion src/reactpy/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def sync_inputs(event: dict[str, Any]) -> None:

inputs: list[VdomDict] = []
for attrs in attributes:
inputs.append(html.input({**attrs, "on_change": sync_inputs, "value": value}))
inputs.append(html.input({**attrs, "onChange": sync_inputs, "value": value}))

return inputs

Expand Down
9 changes: 1 addition & 8 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,10 @@
capture_reactpy_logs,
clear_reactpy_web_modules_dir,
)
from reactpy.testing.common import GITHUB_ACTIONS

REACTPY_ASYNC_RENDERING.set_current(True)
REACTPY_DEBUG.set_current(True)
GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") in {
"y",
"yes",
"t",
"true",
"on",
"1",
}


def pytest_addoption(parser: Parser) -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_asgi/test_standalone.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def Counter():
return reactpy.html.button(
{
"id": "counter",
"on_click": lambda event: set_count(lambda old_count: old_count + 1),
"onClick": lambda event: set_count(lambda old_count: old_count + 1),
},
f"Count: {count}",
)
Expand Down
Loading