From 78a29b88f9b10365c05a421a330daf37868f9cc3 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 12 Feb 2023 13:21:04 -0800 Subject: [PATCH 01/19] revert 764a5a8c9e083d4cc8ef69becb212953ce16b324 - but preserve ability to declare attributes with snake_case - move 'key' to be an attribute rather than keyword argument in constructor + make auto converter for this change based on the one used for the "new" `*args` and `**kwargs` syntax we are reverting. --- .pre-commit-config.yaml | 6 +- docs/examples.py | 2 +- docs/source/_custom_js/package-lock.json | 16 +- docs/source/_exts/custom_autosectionlabel.py | 5 +- docs/source/about/changelog.rst | 4 +- .../_examples/adding_state_variable/main.py | 4 +- .../_examples/isolated_state/main.py | 12 +- .../multiple_state_variables/main.py | 8 +- .../when_variables_are_not_enough/main.py | 4 +- .../_examples/dict_remove.py | 13 +- .../_examples/dict_update.py | 12 +- .../_examples/list_insert.py | 4 +- .../_examples/list_re_order.py | 4 +- .../_examples/list_remove.py | 6 +- .../_examples/list_replace.py | 2 +- .../_examples/moving_dot.py | 36 +- .../_examples/moving_dot_broken.py | 36 +- .../_examples/set_remove.py | 24 +- .../_examples/set_update.py | 24 +- .../_examples/delay_before_count_updater.py | 2 +- .../_examples/delay_before_set_count.py | 2 +- .../_examples/set_color_3_times.py | 6 +- .../_examples/set_state_function.py | 2 +- .../_examples/audio_player.py | 8 +- .../_examples/button_async_handlers.py | 2 +- .../_examples/button_handler_as_arg.py | 2 +- .../_examples/button_prints_event.py | 2 +- .../_examples/button_prints_message.py | 2 +- .../prevent_default_event_actions.py | 6 +- .../_examples/stop_event_propagation.py | 20 +- .../_examples/delayed_print_after_set.py | 2 +- .../_examples/print_chat_message.py | 25 +- .../_examples/print_count_after_set.py | 2 +- .../_examples/send_message.py | 17 +- .../_examples/set_counter_3_times.py | 2 +- .../html-with-idom/index.rst | 9 +- .../_examples/nested_photos.py | 8 +- .../_examples/parametrized_photos.py | 8 +- .../_examples/simple_photo.py | 6 +- .../_examples/wrap_in_div.py | 2 +- .../_examples/wrap_in_fragment.py | 2 +- .../_examples/filterable_list/main.py | 2 +- .../_examples/synced_inputs/main.py | 4 +- .../_examples/character_movement/main.py | 22 +- .../source/reference/_examples/click_count.py | 3 +- .../reference/_examples/matplotlib_plot.py | 20 +- .../reference/_examples/simple_dashboard.py | 4 +- docs/source/reference/_examples/slideshow.py | 8 +- docs/source/reference/_examples/snake_game.py | 47 ++- docs/source/reference/_examples/todo.py | 4 +- .../_examples/use_reducer_counter.py | 6 +- .../reference/_examples/use_state_counter.py | 6 +- requirements/pkg-deps.txt | 1 - scripts/fix_vdom_constructor_usage.py | 370 ------------------ .../idom-client-react/src/element-utils.js | 2 +- .../public/assets/idom-logo-square-small.svg | 45 --- src/idom/_warnings.py | 6 +- src/idom/backend/_common.py | 8 +- src/idom/backend/tornado.py | 9 +- src/idom/core/component.py | 6 +- src/idom/core/hooks.py | 40 +- src/idom/core/types.py | 43 +- src/idom/core/vdom.py | 158 ++++---- src/idom/html.py | 33 +- src/idom/sample.py | 8 +- src/idom/utils.py | 20 +- src/idom/web/module.py | 34 +- src/idom/widgets.py | 16 +- tests/test_backend/test__common.py | 6 +- tests/test_backend/test_all.py | 14 +- tests/test_client.py | 38 +- tests/test_core/test_component.py | 10 +- tests/test_core/test_events.py | 30 +- tests/test_core/test_hooks.py | 26 +- tests/test_core/test_layout.py | 29 +- tests/test_core/test_serve.py | 6 +- tests/test_core/test_vdom.py | 36 +- tests/test_html.py | 16 +- tests/test_testing.py | 4 +- tests/test_utils.py | 30 +- tests/test_web/test_module.py | 19 +- tests/test_widgets.py | 4 +- 82 files changed, 640 insertions(+), 912 deletions(-) delete mode 100644 scripts/fix_vdom_constructor_usage.py delete mode 100644 src/client/public/assets/idom-logo-square-small.svg diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8232c83e4..b47a49829 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,14 +1,14 @@ repos: - repo: https://github.com/ambv/black - rev: 22.6.0 + rev: 23.1.0 hooks: - id: black - repo: https://github.com/pycqa/isort - rev: 5.6.3 + rev: 5.12.0 hooks: - id: isort name: isort - repo: https://github.com/pre-commit/mirrors-prettier - rev: "v2.5.1" + rev: v2.7.1 hooks: - id: prettier diff --git a/docs/examples.py b/docs/examples.py index 5b273503e..7b9a5160a 100644 --- a/docs/examples.py +++ b/docs/examples.py @@ -122,7 +122,7 @@ def Wrapper(): def PrintView(): text, set_text = idom.hooks.use_state(print_buffer.getvalue()) print_buffer.set_callback(set_text) - return idom.html.pre(text, class_name="printout") if text else idom.html.div() + return idom.html.pre({"class": "printout"}, text) if text else idom.html.div() return Wrapper() diff --git a/docs/source/_custom_js/package-lock.json b/docs/source/_custom_js/package-lock.json index 58de595e2..ec1c3b1c7 100644 --- a/docs/source/_custom_js/package-lock.json +++ b/docs/source/_custom_js/package-lock.json @@ -23,8 +23,8 @@ "integrity": "sha512-pIK5eNwFSHKXg7ClpASWFVKyZDYxz59MSFpVaX/OqJFkrJaAxBuhKGXNTMXmuyWOL5Iyvb/ErwwDRxQRzMNkfQ==", "license": "MIT", "dependencies": { - "htm": "^3.0.3", - "json-pointer": "^0.6.2" + "fast-json-patch": "^3.0.0-1", + "htm": "^3.0.3" }, "devDependencies": { "jsdom": "16.5.0", @@ -37,6 +37,11 @@ "react-dom": ">=16" } }, + "../../../src/client/packages/idom-client-react/node_modules/fast-json-patch": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.0.tgz", + "integrity": "sha512-IhpytlsVTRndz0hU5t0/MGzS/etxLlfrpG5V5M9mVbuj9TrJLWaMfsox9REM5rkuGX0T+5qjpe8XA1o0gZ42nA==" + }, "../../../src/client/packages/idom-client-react/node_modules/htm": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.0.tgz", @@ -597,14 +602,19 @@ "idom-client-react": { "version": "file:../../../src/client/packages/idom-client-react", "requires": { + "fast-json-patch": "^3.0.0-1", "htm": "^3.0.3", "jsdom": "16.5.0", - "json-pointer": "^0.6.2", "lodash": "^4.17.21", "prettier": "^2.5.1", "uvu": "^0.5.1" }, "dependencies": { + "fast-json-patch": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.0.tgz", + "integrity": "sha512-IhpytlsVTRndz0hU5t0/MGzS/etxLlfrpG5V5M9mVbuj9TrJLWaMfsox9REM5rkuGX0T+5qjpe8XA1o0gZ42nA==" + }, "htm": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.0.tgz", diff --git a/docs/source/_exts/custom_autosectionlabel.py b/docs/source/_exts/custom_autosectionlabel.py index 7bb659230..573bc35dd 100644 --- a/docs/source/_exts/custom_autosectionlabel.py +++ b/docs/source/_exts/custom_autosectionlabel.py @@ -5,7 +5,7 @@ """ from fnmatch import fnmatch -from typing import Any, cast +from typing import Any, Dict, cast from docutils import nodes from docutils.nodes import Node @@ -30,6 +30,7 @@ def get_node_depth(node: Node) -> int: def register_sections_as_label(app: Sphinx, document: Node) -> None: docname = app.env.docname + print(docname) for pattern in app.config.autosectionlabel_skip_docs: if fnmatch(docname, pattern): @@ -66,7 +67,7 @@ def register_sections_as_label(app: Sphinx, document: Node) -> None: domain.labels[name] = docname, labelid, sectname -def setup(app: Sphinx) -> dict[str, Any]: +def setup(app: Sphinx) -> Dict[str, Any]: app.add_config_value("autosectionlabel_prefix_document", False, "env") app.add_config_value("autosectionlabel_maxdepth", None, "env") app.add_config_value("autosectionlabel_skip_docs", [], "env") diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 2a48d6f37..f9b58e0d2 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -23,7 +23,9 @@ more info, see the :ref:`Contributor Guide `. Unreleased ---------- -No changes. +**Changed** + +- :pull:`919` - reverts :pull:`841` as per the conclusion in :discussion:`916` v1.0.0-a3 diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/main.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/main.py index 9c1c301e8..724831f89 100644 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/main.py +++ b/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/main.py @@ -25,10 +25,10 @@ def handle_click(event): url = sculpture["url"] return html.div( - html.button("Next", on_click=handle_click), + html.button({"onClick": handle_click}, "Next"), html.h2(name, " by ", artist), html.p(f"({bounded_index + 1} of {len(sculpture_data)})"), - html.img(src=url, alt=alt, style={"height": "200px"}), + html.img({"src": url, "alt": alt, "style": {"height": "200px"}}), html.p(description), ) diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/main.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/main.py index e235856b9..08a53d1c6 100644 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/main.py +++ b/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/main.py @@ -29,14 +29,14 @@ def handle_more_click(event): url = sculpture["url"] return html.div( - html.button("Next", on_click=handle_next_click), + html.button({"onClick": handle_next_click}, "Next"), html.h2(name, " by ", artist), html.p(f"({bounded_index + 1} or {len(sculpture_data)})"), - html.img(src=url, alt=alt, style={"height": "200px"}), + html.img({"src": url, "alt": alt, "style": {"height": "200px"}}), html.div( html.button( - f"{('Show' if show_more else 'Hide')} details", - on_click=handle_more_click, + {"onClick": handle_more_click}, + f"{'Show' if show_more else 'Hide'} details", ), (html.p(description) if show_more else ""), ), @@ -46,8 +46,8 @@ def handle_more_click(event): @component def App(): return html.div( - html.section(Gallery(), style={"width": "50%", "float": "left"}), - html.section(Gallery(), style={"width": "50%", "float": "left"}), + html.section({"style": {"width": "50%", "float": "left"}}, Gallery()), + html.section({"style": {"width": "50%", "float": "left"}}, Gallery()), ) diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/main.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/main.py index e9a9b5648..3e7f7bde4 100644 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/main.py +++ b/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/main.py @@ -29,14 +29,14 @@ def handle_more_click(event): url = sculpture["url"] return html.div( - html.button("Next", on_click=handle_next_click), + html.button({"onClick": handle_next_click}, "Next"), html.h2(name, " by ", artist), html.p(f"({bounded_index + 1} or {len(sculpture_data)})"), - html.img(src=url, alt=alt, style={"height": "200px"}), + html.img({"src": url, "alt": alt, "style": {"height": "200px"}}), html.div( html.button( - f"{('Show' if show_more else 'Hide')} details", - on_click=handle_more_click, + {"onClick": handle_more_click}, + f"{'Show' if show_more else 'Hide'} details", ), (html.p(description) if show_more else ""), ), diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/main.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/main.py index 5647418b2..f8679cbfc 100644 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/main.py +++ b/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/main.py @@ -31,10 +31,10 @@ def handle_click(event): url = sculpture["url"] return html.div( - html.button("Next", on_click=handle_click), + html.button({"onClick": handle_click}, "Next"), html.h2(name, " by ", artist), html.p(f"({bounded_index + 1} or {len(sculpture_data)})"), - html.img(src=url, alt=alt, style={"height": "200px"}), + html.img({"src": url, "alt": alt, "style": {"height": "200px"}}), html.p(description), ) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_remove.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_remove.py index 03444e250..cf1955301 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_remove.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_remove.py @@ -26,21 +26,26 @@ def handle_click(event): return handle_click return html.div( - html.button("add term", on_click=handle_add_click), + html.button({"onClick": handle_add_click}, "add term"), html.label( "Term: ", - html.input(value=term_to_add, on_change=handle_term_to_add_change), + html.input({"value": term_to_add, "onChange": handle_term_to_add_change}), ), html.label( "Definition: ", html.input( - value=definition_to_add, on_change=handle_definition_to_add_change + { + "value": definition_to_add, + "onChange": handle_definition_to_add_change, + } ), ), html.hr(), [ html.div( - html.button("delete term", on_click=make_delete_click_handler(term)), + html.button( + {"onClick": make_delete_click_handler(term)}, "delete term" + ), html.dt(term), html.dd(definition), key=term, diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_update.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_update.py index d9dd35dff..92085c0b6 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_update.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_update.py @@ -23,15 +23,21 @@ def handle_email_change(event): return html.div( html.label( "First name: ", - html.input(value=person["first_name"], on_change=handle_first_name_change), + html.input( + {"value": person["first_name"], "onChange": handle_first_name_change}, + ), ), html.label( "Last name: ", - html.input(value=person["last_name"], on_change=handle_last_name_change), + html.input( + {"value": person["last_name"], "onChange": handle_last_name_change}, + ), ), html.label( "Email: ", - html.input(value=person["email"], on_change=handle_email_change), + html.input( + {"value": person["email"], "onChange": handle_email_change}, + ), ), html.p(f"{person['first_name']} {person['last_name']} {person['email']}"), ) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_insert.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_insert.py index 35cf2ce5d..374198f45 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_insert.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_insert.py @@ -16,8 +16,8 @@ def handle_click(event): return html.div( html.h1("Inspiring sculptors:"), - html.input(value=artist_to_add, on_change=handle_change), - html.button("add", on_click=handle_click), + html.input({"value": artist_to_add, "onChange": handle_change}), + html.button({"onClick": handle_click}, "add"), html.ul([html.li(name, key=name) for name in artists]), ) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_re_order.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_re_order.py index e8ab5f7e1..32a0e47c0 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_re_order.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_re_order.py @@ -15,8 +15,8 @@ def handle_reverse_click(event): return html.div( html.h1("Inspiring sculptors:"), - html.button("sort", on_click=handle_sort_click), - html.button("reverse", on_click=handle_reverse_click), + html.button({"onClick": handle_sort_click}, "sort"), + html.button({"onClick": handle_reverse_click}, "reverse"), html.ul([html.li(name, key=name) for name in artists]), ) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_remove.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_remove.py index f37c2ff9f..170deb2f4 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_remove.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_remove.py @@ -24,13 +24,13 @@ def handle_click(event): return html.div( html.h1("Inspiring sculptors:"), - html.input(value=artist_to_add, on_change=handle_change), - html.button("add", on_click=handle_add_click), + html.input({"value": artist_to_add, "onChange": handle_change}), + html.button({"onClick": handle_add_click}, "add"), html.ul( [ html.li( name, - html.button("delete", on_click=make_handle_delete_click(index)), + html.button({"onClick": make_handle_delete_click(index)}, "delete"), key=name, ) for index, name in enumerate(artists) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_replace.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_replace.py index 7cfcd0bf3..2fafcfde8 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_replace.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_replace.py @@ -16,7 +16,7 @@ def handle_click(event): [ html.li( count, - html.button("+1", on_click=make_increment_click_handler(index)), + html.button({"onClick": make_increment_click_handler(index)}, "+1"), key=index, ) for index, count in enumerate(counters) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot.py index 58e0b386f..d3edb8590 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot.py @@ -16,25 +16,29 @@ async def handle_pointer_move(event): ) return html.div( + { + "onPointerMove": handle_pointer_move, + "style": { + "position": "relative", + "height": "200px", + "width": "100%", + "backgroundColor": "white", + }, + }, html.div( - style={ - "position": "absolute", - "background_color": "red", - "border_radius": "50%", - "width": "20px", - "height": "20px", - "left": "-10px", - "top": "-10px", - "transform": f"translate({position['x']}px, {position['y']}px)", + { + "style": { + "position": "absolute", + "backgroundColor": "red", + "borderRadius": "50%", + "width": "20px", + "height": "20px", + "left": "-10px", + "top": "-10px", + "transform": f"translate({position['x']}px, {position['y']}px)", + }, } ), - on_pointer_move=handle_pointer_move, - style={ - "position": "relative", - "height": "200px", - "width": "100%", - "background_color": "white", - }, ) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot_broken.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot_broken.py index 3f8f738db..90885e7fe 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot_broken.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot_broken.py @@ -14,25 +14,29 @@ def handle_pointer_move(event): position["y"] = event["clientY"] - outer_div_bounds["y"] return html.div( + { + "onPointerMove": handle_pointer_move, + "style": { + "position": "relative", + "height": "200px", + "width": "100%", + "backgroundColor": "white", + }, + }, html.div( - style={ - "position": "absolute", - "background_color": "red", - "border_radius": "50%", - "width": "20px", - "height": "20px", - "left": "-10px", - "top": "-10px", - "transform": f"translate({position['x']}px, {position['y']}px)", + { + "style": { + "position": "absolute", + "backgroundColor": "red", + "borderRadius": "50%", + "width": "20px", + "height": "20px", + "left": "-10px", + "top": "-10px", + "transform": f"translate({position['x']}px, {position['y']}px)", + }, } ), - on_pointer_move=handle_pointer_move, - style={ - "position": "relative", - "height": "200px", - "width": "100%", - "background_color": "white", - }, ) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py index b2d830909..4c4905350 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py @@ -16,23 +16,25 @@ def handle_click(event): return handle_click return html.div( + {"style": {"display": "flex", "flex-direction": "row"}}, [ html.div( - key=index, - on_click=make_handle_click(index), - style={ - "height": "30px", - "width": "30px", - "background_color": "black" - if index in selected_indices - else "white", - "outline": "1px solid grey", - "cursor": "pointer", + { + "onClick": make_handle_click(index), + "style": { + "height": "30px", + "width": "30px", + "backgroundColor": ( + "black" if index in selected_indices else "white" + ), + "outline": "1px solid grey", + "cursor": "pointer", + }, }, + key=index, ) for index in range(line_size) ], - style={"display": "flex", "flex-direction": "row"}, ) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py index 37bd7a591..9e1077cb0 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py @@ -13,23 +13,25 @@ def handle_click(event): return handle_click return html.div( + {"style": {"display": "flex", "flex-direction": "row"}}, [ html.div( - key=index, - on_click=make_handle_click(index), - style={ - "height": "30px", - "width": "30px", - "background_color": "black" - if index in selected_indices - else "white", - "outline": "1px solid grey", - "cursor": "pointer", + { + "onClick": make_handle_click(index), + "style": { + "height": "30px", + "width": "30px", + "backgroundColor": ( + "black" if index in selected_indices else "white" + ), + "outline": "1px solid grey", + "cursor": "pointer", + }, }, + key=index, ) for index in range(line_size) ], - style={"display": "flex", "flex-direction": "row"}, ) diff --git a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/delay_before_count_updater.py b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/delay_before_count_updater.py index 6b24f0110..0c000477e 100644 --- a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/delay_before_count_updater.py +++ b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/delay_before_count_updater.py @@ -13,7 +13,7 @@ async def handle_click(event): return html.div( html.h1(number), - html.button("Increment", on_click=handle_click), + html.button({"onClick": handle_click}, "Increment"), ) diff --git a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/delay_before_set_count.py b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/delay_before_set_count.py index 062e57e19..024df12e7 100644 --- a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/delay_before_set_count.py +++ b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/delay_before_set_count.py @@ -13,7 +13,7 @@ async def handle_click(event): return html.div( html.h1(number), - html.button("Increment", on_click=handle_click), + html.button({"onClick": handle_click}, "Increment"), ) diff --git a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_color_3_times.py b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_color_3_times.py index 313035a6e..e755c35b9 100644 --- a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_color_3_times.py +++ b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_color_3_times.py @@ -15,9 +15,11 @@ def handle_reset(event): return html.div( html.button( - "Set Color", on_click=handle_click, style={"background_color": color} + {"onClick": handle_click, "style": {"backgroundColor": color}}, "Set Color" + ), + html.button( + {"onClick": handle_reset, "style": {"backgroundColor": color}}, "Reset" ), - html.button("Reset", on_click=handle_reset, style={"background_color": color}), ) diff --git a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_state_function.py b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_state_function.py index 36cb5b395..ec3193de9 100644 --- a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_state_function.py +++ b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_state_function.py @@ -17,7 +17,7 @@ def handle_click(event): return html.div( html.h1(number), - html.button("Increment", on_click=handle_click), + html.button({"onClick": handle_click}, "Increment"), ) diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/audio_player.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/audio_player.py index e6e3ab543..582588a8c 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/audio_player.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/audio_player.py @@ -8,9 +8,11 @@ def PlayDinosaurSound(): event, set_event = idom.hooks.use_state(None) return idom.html.div( idom.html.audio( - controls=True, - on_time_update=lambda e: set_event(e), - src="https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", + { + "controls": True, + "onTimeUpdate": lambda e: set_event(e), + "src": "https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", + } ), idom.html.pre(json.dumps(event, indent=2)), ) diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_async_handlers.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_async_handlers.py index a11b3a40c..a355f6142 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_async_handlers.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_async_handlers.py @@ -9,7 +9,7 @@ async def handle_event(event): await asyncio.sleep(delay) print(message) - return html.button(message, on_click=handle_event) + return html.button({"onClick": handle_event}, message) @component diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_handler_as_arg.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_handler_as_arg.py index 4a576123b..4de22a024 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_handler_as_arg.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_handler_as_arg.py @@ -3,7 +3,7 @@ @component def Button(display_text, on_click): - return html.button(display_text, on_click=on_click) + return html.button({"onClick": on_click}, display_text) @component diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_event.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_event.py index bb4ac6b73..eac05a588 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_event.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_event.py @@ -6,7 +6,7 @@ def Button(): def handle_event(event): print(event) - return html.button("Click me!", on_click=handle_event) + return html.button({"onClick": handle_event}, "Click me!") run(Button) diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_message.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_message.py index 4be2fc1d4..f5ee69f80 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_message.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_message.py @@ -6,7 +6,7 @@ def PrintButton(display_text, message_text): def handle_event(event): print(message_text) - return html.button(display_text, on_click=handle_event) + return html.button({"onClick": handle_event}, display_text) @component diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/prevent_default_event_actions.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/prevent_default_event_actions.py index cd2870a46..7e8ef9938 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/prevent_default_event_actions.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/prevent_default_event_actions.py @@ -6,9 +6,11 @@ def DoNotChangePages(): return html.div( html.p("Normally clicking this link would take you to a new page"), html.a( + { + "onClick": event(lambda event: None, prevent_default=True), + "href": "https://google.com", + }, "https://google.com", - on_click=event(lambda event: None, prevent_default=True), - href="https://google.com", ), ) diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/stop_event_propagation.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/stop_event_propagation.py index 4a2079d43..e87bae026 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/stop_event_propagation.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/stop_event_propagation.py @@ -8,21 +8,25 @@ def DivInDiv(): outer_count, set_outer_count = hooks.use_state(0) div_in_div = html.div( + { + "onClick": lambda event: set_outer_count(outer_count + 1), + "style": {"height": "100px", "width": "100px", "backgroundColor": "red"}, + }, html.div( - on_click=event( - lambda event: set_inner_count(inner_count + 1), - stop_propagation=stop_propagatation, - ), - style={"height": "50px", "width": "50px", "background_color": "blue"}, + { + "onClick": event( + lambda event: set_inner_count(inner_count + 1), + stop_propagation=stop_propagatation, + ), + "style": {"height": "50px", "width": "50px", "backgroundColor": "blue"}, + }, ), - on_click=lambda event: set_outer_count(outer_count + 1), - style={"height": "100px", "width": "100px", "background_color": "red"}, ) return html.div( html.button( + {"onClick": lambda event: set_stop_propagatation(not stop_propagatation)}, "Toggle Propogation", - on_click=lambda event: set_stop_propagatation(not stop_propagatation), ), html.pre(f"Will propagate: {not stop_propagatation}"), html.pre(f"Inner click count: {inner_count}"), diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/delayed_print_after_set.py b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/delayed_print_after_set.py index 99c3aab80..5471616d4 100644 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/delayed_print_after_set.py +++ b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/delayed_print_after_set.py @@ -15,7 +15,7 @@ async def handle_click(event): return html.div( html.h1(number), - html.button("Increment", on_click=handle_click), + html.button({"onClick": handle_click}, "Increment"), ) diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_chat_message.py b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_chat_message.py index 2e0df7abf..35fbc23fb 100644 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_chat_message.py +++ b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_chat_message.py @@ -16,24 +16,27 @@ async def handle_submit(event): print(f"Sent '{message}' to {recipient}") return html.form( + {"onSubmit": handle_submit, "style": {"display": "inline-grid"}}, html.label( "To: ", html.select( - html.option("Alice", value="Alice"), - html.option("Bob", value="Bob"), - value=recipient, - on_change=lambda event: set_recipient(event["target"]["value"]), + { + "value": recipient, + "onChange": lambda event: set_recipient(event["target"]["value"]), + }, + html.option({"value": "Alice"}, "Alice"), + html.option({"value": "Bob"}, "Bob"), ), ), html.input( - type="text", - placeholder="Your message...", - value=message, - on_change=lambda event: set_message(event["target"]["value"]), + { + "type": "text", + "placeholder": "Your message...", + "value": message, + "onChange": lambda event: set_message(event["target"]["value"]), + } ), - html.button("Send", type="submit"), - on_submit=handle_submit, - style={"display": "inline-grid"}, + html.button({"type": "submit"}, "Send"), ) diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_count_after_set.py b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_count_after_set.py index d3f09253e..039a261d9 100644 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_count_after_set.py +++ b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_count_after_set.py @@ -11,7 +11,7 @@ def handle_click(event): return html.div( html.h1(number), - html.button("Increment", on_click=handle_click), + html.button({"onClick": handle_click}, "Increment"), ) diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/send_message.py b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/send_message.py index fa1bfa7d8..0ceaf8850 100644 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/send_message.py +++ b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/send_message.py @@ -9,7 +9,9 @@ def App(): if is_sent: return html.div( html.h1("Message sent!"), - html.button("Send new message?", on_click=lambda event: set_is_sent(False)), + html.button( + {"onClick": lambda event: set_is_sent(False)}, "Send new message?" + ), ) @event(prevent_default=True) @@ -18,14 +20,15 @@ def handle_submit(event): set_is_sent(True) return html.form( + {"onSubmit": handle_submit, "style": {"display": "inline-grid"}}, html.textarea( - placeholder="Your message here...", - value=message, - on_change=lambda event: set_message(event["target"]["value"]), + { + "placeholder": "Your message here...", + "value": message, + "onChange": lambda event: set_message(event["target"]["value"]), + } ), - html.button("Send", type="submit"), - on_submit=handle_submit, - style={"display": "inline-grid"}, + html.button({"type": "submit"}, "Send"), ) diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/set_counter_3_times.py b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/set_counter_3_times.py index 0a8318231..24801d47b 100644 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/set_counter_3_times.py +++ b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/set_counter_3_times.py @@ -12,7 +12,7 @@ def handle_click(event): return html.div( html.h1(number), - html.button("Increment", on_click=handle_click), + html.button({"onClick": handle_click}, "Increment"), ) diff --git a/docs/source/guides/creating-interfaces/html-with-idom/index.rst b/docs/source/guides/creating-interfaces/html-with-idom/index.rst index c3a5c16cc..6001b7a69 100644 --- a/docs/source/guides/creating-interfaces/html-with-idom/index.rst +++ b/docs/source/guides/creating-interfaces/html-with-idom/index.rst @@ -92,10 +92,11 @@ string, we use a dictionary. Given this, you can rewrite the ```` element a .. testcode:: html.img( - src="https://picsum.photos/id/237/500/300", - style={"width": "50%", "margin_left": "25%"}, - alt="Billie Holiday", - tab_index="0", + { + "src": "https://picsum.photos/id/237/500/300", + "style": {"width": "50%", "margin_left": "25%"}, + "alt": "Billie Holiday", + } ) .. raw:: html diff --git a/docs/source/guides/creating-interfaces/your-first-components/_examples/nested_photos.py b/docs/source/guides/creating-interfaces/your-first-components/_examples/nested_photos.py index 59796e49a..4c512b7e6 100644 --- a/docs/source/guides/creating-interfaces/your-first-components/_examples/nested_photos.py +++ b/docs/source/guides/creating-interfaces/your-first-components/_examples/nested_photos.py @@ -4,9 +4,11 @@ @component def Photo(): return html.img( - src="https://picsum.photos/id/274/500/300", - style={"width": "30%"}, - alt="Ray Charles", + { + "src": "https://picsum.photos/id/274/500/300", + "style": {"width": "30%"}, + "alt": "Ray Charles", + } ) diff --git a/docs/source/guides/creating-interfaces/your-first-components/_examples/parametrized_photos.py b/docs/source/guides/creating-interfaces/your-first-components/_examples/parametrized_photos.py index 2d5768d58..7eacb8f36 100644 --- a/docs/source/guides/creating-interfaces/your-first-components/_examples/parametrized_photos.py +++ b/docs/source/guides/creating-interfaces/your-first-components/_examples/parametrized_photos.py @@ -4,9 +4,11 @@ @component def Photo(alt_text, image_id): return html.img( - src=f"https://picsum.photos/id/{image_id}/500/200", - style={"width": "50%"}, - alt=alt_text, + { + "src": f"https://picsum.photos/id/{image_id}/500/200", + "style": {"width": "50%"}, + "alt": alt_text, + } ) diff --git a/docs/source/guides/creating-interfaces/your-first-components/_examples/simple_photo.py b/docs/source/guides/creating-interfaces/your-first-components/_examples/simple_photo.py index f5ccc5027..c6b92c652 100644 --- a/docs/source/guides/creating-interfaces/your-first-components/_examples/simple_photo.py +++ b/docs/source/guides/creating-interfaces/your-first-components/_examples/simple_photo.py @@ -4,7 +4,11 @@ @component def Photo(): return html.img( - src="https://picsum.photos/id/237/500/300", style={"width": "50%"}, alt="Puppy" + { + "src": "https://picsum.photos/id/237/500/300", + "style": {"width": "50%"}, + "alt": "Puppy", + } ) diff --git a/docs/source/guides/creating-interfaces/your-first-components/_examples/wrap_in_div.py b/docs/source/guides/creating-interfaces/your-first-components/_examples/wrap_in_div.py index f0ba25e84..2ddcd1060 100644 --- a/docs/source/guides/creating-interfaces/your-first-components/_examples/wrap_in_div.py +++ b/docs/source/guides/creating-interfaces/your-first-components/_examples/wrap_in_div.py @@ -5,7 +5,7 @@ def MyTodoList(): return html.div( html.h1("My Todo List"), - html.img(src="https://picsum.photos/id/0/500/300"), + html.img({"src": "https://picsum.photos/id/0/500/300"}), html.ul(html.li("The first thing I need to do is...")), ) diff --git a/docs/source/guides/creating-interfaces/your-first-components/_examples/wrap_in_fragment.py b/docs/source/guides/creating-interfaces/your-first-components/_examples/wrap_in_fragment.py index 85b9b3ceb..027e253bf 100644 --- a/docs/source/guides/creating-interfaces/your-first-components/_examples/wrap_in_fragment.py +++ b/docs/source/guides/creating-interfaces/your-first-components/_examples/wrap_in_fragment.py @@ -5,7 +5,7 @@ def MyTodoList(): return html._( html.h1("My Todo List"), - html.img(src="https://picsum.photos/id/0/500/200"), + html.img({"src": "https://picsum.photos/id/0/500/200"}), html.ul(html.li("The first thing I need to do is...")), ) diff --git a/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py b/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py index 619a35cff..9b0658371 100644 --- a/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py +++ b/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py @@ -21,7 +21,7 @@ def handle_change(event): set_value(event["target"]["value"]) return html.label( - "Search by Food Name: ", html.input(value=value, on_change=handle_change) + "Search by Food Name: ", html.input({"value": value, "onChange": handle_change}) ) diff --git a/docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py b/docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py index 64bcb1aa7..dcc3e1246 100644 --- a/docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py +++ b/docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py @@ -15,7 +15,9 @@ def Input(label, value, set_value): def handle_change(event): set_value(event["target"]["value"]) - return html.label(label + " ", html.input(value=value, on_change=handle_change)) + return html.label( + label + " ", html.input({"value": value, "onChange": handle_change}) + ) run(SyncedInputs) diff --git a/docs/source/reference/_examples/character_movement/main.py b/docs/source/reference/_examples/character_movement/main.py index 24b47580f..fbf257a32 100644 --- a/docs/source/reference/_examples/character_movement/main.py +++ b/docs/source/reference/_examples/character_movement/main.py @@ -36,7 +36,15 @@ def Scene(): position, set_position = use_state(Position(100, 100, 0)) return html.div( + {"style": {"width": "225px"}}, html.div( + { + "style": { + "width": "200px", + "height": "200px", + "backgroundColor": "slategray", + } + }, image( "png", CHARACTER_IMAGE, @@ -49,15 +57,13 @@ def Scene(): } }, ), - style={"width": "200px", "height": "200px", "backgroundColor": "slategray"}, ), - html.button("Move Left", on_click=lambda e: set_position(translate(x=-10))), - html.button("Move Right", on_click=lambda e: set_position(translate(x=10))), - html.button("Move Up", on_click=lambda e: set_position(translate(y=-10))), - html.button("Move Down", on_click=lambda e: set_position(translate(y=10))), - html.button("Rotate Left", on_click=lambda e: set_position(rotate(-30))), - html.button("Rotate Right", on_click=lambda e: set_position(rotate(30))), - style={"width": "225px"}, + html.button({"onClick": lambda e: set_position(translate(x=-10))}, "Move Left"), + html.button({"onClick": lambda e: set_position(translate(x=10))}, "Move Right"), + html.button({"onClick": lambda e: set_position(translate(y=-10))}, "Move Up"), + html.button({"onClick": lambda e: set_position(translate(y=10))}, "Move Down"), + html.button({"onClick": lambda e: set_position(rotate(-30))}, "Rotate Left"), + html.button({"onClick": lambda e: set_position(rotate(30))}, "Rotate Right"), ) diff --git a/docs/source/reference/_examples/click_count.py b/docs/source/reference/_examples/click_count.py index 491fca839..6f30ce517 100644 --- a/docs/source/reference/_examples/click_count.py +++ b/docs/source/reference/_examples/click_count.py @@ -6,7 +6,8 @@ def ClickCount(): count, set_count = idom.hooks.use_state(0) return idom.html.button( - [f"Click count: {count}"], on_click=lambda event: set_count(count + 1) + {"onClick": lambda event: set_count(count + 1)}, + [f"Click count: {count}"], ) diff --git a/docs/source/reference/_examples/matplotlib_plot.py b/docs/source/reference/_examples/matplotlib_plot.py index e219e4e96..6dffb79db 100644 --- a/docs/source/reference/_examples/matplotlib_plot.py +++ b/docs/source/reference/_examples/matplotlib_plot.py @@ -39,8 +39,8 @@ def del_input(): return idom.html.div( idom.html.div( "add/remove term:", - idom.html.button("+", on_click=lambda event: add_input()), - idom.html.button("-", on_click=lambda event: del_input()), + idom.html.button({"onClick": lambda event: add_input()}, "+"), + idom.html.button({"onClick": lambda event: del_input()}, "-"), ), inputs, ) @@ -58,10 +58,20 @@ def plot(title, x, y): def poly_coef_input(index, callback): return idom.html.div( - idom.html.label("C", idom.html.sub(index), " × X", idom.html.sup(index)), - idom.html.input(type="number", on_change=callback), + {"style": {"margin-top": "5px"}}, + idom.html.label( + "C", + idom.html.sub(index), + " × X", + idom.html.sup(index), + ), + idom.html.input( + { + "type": "number", + "onChange": callback, + }, + ), key=index, - style={"margin_top": "5px"}, ) diff --git a/docs/source/reference/_examples/simple_dashboard.py b/docs/source/reference/_examples/simple_dashboard.py index 4f7ce2a42..540082f58 100644 --- a/docs/source/reference/_examples/simple_dashboard.py +++ b/docs/source/reference/_examples/simple_dashboard.py @@ -83,10 +83,10 @@ def update_value(value): set_value_callback(value) return idom.html.fieldset( - idom.html.legend(label, style={"font_size": "medium"}), + {"class": "number-input-container"}, + idom.html.legend({"style": {"font-size": "medium"}}, label), Input(update_value, "number", value, attributes=attrs, cast=float), Input(update_value, "range", value, attributes=attrs, cast=float), - class_name="number-input-container", ) diff --git a/docs/source/reference/_examples/slideshow.py b/docs/source/reference/_examples/slideshow.py index 49f732aed..0d3116ac4 100644 --- a/docs/source/reference/_examples/slideshow.py +++ b/docs/source/reference/_examples/slideshow.py @@ -9,9 +9,11 @@ def next_image(event): set_index(index + 1) return idom.html.img( - src=f"https://picsum.photos/id/{index}/800/300", - style={"cursor": "pointer"}, - on_click=next_image, + { + "src": f"https://picsum.photos/id/{index}/800/300", + "style": {"cursor": "pointer"}, + "onClick": next_image, + } ) diff --git a/docs/source/reference/_examples/snake_game.py b/docs/source/reference/_examples/snake_game.py index cf0328ef4..92fe054f0 100644 --- a/docs/source/reference/_examples/snake_game.py +++ b/docs/source/reference/_examples/snake_game.py @@ -21,7 +21,8 @@ def GameView(): return GameLoop(grid_size=6, block_scale=50, set_game_state=set_game_state) start_button = idom.html.button( - "Start", on_click=lambda event: set_game_state(GameState.play) + {"onClick": lambda event: set_game_state(GameState.play)}, + "Start", ) if game_state == GameState.won: @@ -39,7 +40,7 @@ def GameView(): """ ) - return idom.html.div(menu_style, menu, class_name="snake-game-menu") + return idom.html.div({"className": "snake-game-menu"}, menu_style, menu) class Direction(enum.Enum): @@ -71,7 +72,7 @@ def on_direction_change(event): if direction_vector_sum != (0, 0): direction.current = maybe_new_direction - grid_wrapper = idom.html.div(grid, on_key_down=on_direction_change) + grid_wrapper = idom.html.div({"onKeyDown": on_direction_change}, grid) assign_grid_block_color(grid, food, "blue") @@ -138,46 +139,50 @@ async def interval() -> None: def create_grid(grid_size, block_scale): return idom.html.div( + { + "style": { + "height": f"{block_scale * grid_size}px", + "width": f"{block_scale * grid_size}px", + "cursor": "pointer", + "display": "grid", + "grid-gap": 0, + "grid-template-columns": f"repeat({grid_size}, {block_scale}px)", + "grid-template-rows": f"repeat({grid_size}, {block_scale}px)", + }, + "tabIndex": -1, + }, [ idom.html.div( + {"style": {"height": f"{block_scale}px"}}, [ create_grid_block("black", block_scale, key=i) for i in range(grid_size) ], key=i, - style={"height": f"{block_scale}px"}, ) for i in range(grid_size) ], - style={ - "height": f"{block_scale * grid_size}px", - "width": f"{block_scale * grid_size}px", - "cursor": "pointer", - "display": "grid", - "grid_gap": 0, - "grid_template_columns": f"repeat({grid_size}, {block_scale}px)", - "grid_template_rows": f"repeat({grid_size}, {block_scale}px)", - }, - tab_index=-1, ) def create_grid_block(color, block_scale, key): return idom.html.div( - key=key, - style={ - "height": f"{block_scale}px", - "width": f"{block_scale}px", - "background_color": color, - "outline": "1px solid grey", + { + "style": { + "height": f"{block_scale}px", + "width": f"{block_scale}px", + "backgroundColor": color, + "outline": "1px solid grey", + } }, + key=key, ) def assign_grid_block_color(grid, point, color): x, y = point block = grid["children"][x]["children"][y] - block["attributes"]["style"]["background_color"] = color + block["attributes"]["style"]["backgroundColor"] = color idom.run(GameView) diff --git a/docs/source/reference/_examples/todo.py b/docs/source/reference/_examples/todo.py index 36880e39a..7b1f6f675 100644 --- a/docs/source/reference/_examples/todo.py +++ b/docs/source/reference/_examples/todo.py @@ -17,10 +17,10 @@ async def remove_task(event, index=index): set_items(items[:index] + items[index + 1 :]) task_text = idom.html.td(idom.html.p(text)) - delete_button = idom.html.td(idom.html.button(["x"]), on_click=remove_task) + delete_button = idom.html.td({"onClick": remove_task}, idom.html.button(["x"])) tasks.append(idom.html.tr(task_text, delete_button)) - task_input = idom.html.input(on_key_down=add_new_task) + task_input = idom.html.input({"onKeyDown": add_new_task}) task_table = idom.html.table(tasks) return idom.html.div( diff --git a/docs/source/reference/_examples/use_reducer_counter.py b/docs/source/reference/_examples/use_reducer_counter.py index 5f22490eb..ea1b780a0 100644 --- a/docs/source/reference/_examples/use_reducer_counter.py +++ b/docs/source/reference/_examples/use_reducer_counter.py @@ -17,9 +17,9 @@ def Counter(): count, dispatch = idom.hooks.use_reducer(reducer, 0) return idom.html.div( f"Count: {count}", - idom.html.button("Reset", on_click=lambda event: dispatch("reset")), - idom.html.button("+", on_click=lambda event: dispatch("increment")), - idom.html.button("-", on_click=lambda event: dispatch("decrement")), + idom.html.button({"onClick": lambda event: dispatch("reset")}, "Reset"), + idom.html.button({"onClick": lambda event: dispatch("increment")}, "+"), + idom.html.button({"onClick": lambda event: dispatch("decrement")}, "-"), ) diff --git a/docs/source/reference/_examples/use_state_counter.py b/docs/source/reference/_examples/use_state_counter.py index 27948edec..8626a60b9 100644 --- a/docs/source/reference/_examples/use_state_counter.py +++ b/docs/source/reference/_examples/use_state_counter.py @@ -15,9 +15,9 @@ def Counter(): count, set_count = idom.hooks.use_state(initial_count) return idom.html.div( f"Count: {count}", - idom.html.button("Reset", on_click=lambda event: set_count(initial_count)), - idom.html.button("+", on_click=lambda event: set_count(increment)), - idom.html.button("-", on_click=lambda event: set_count(decrement)), + idom.html.button({"onClick": lambda event: set_count(initial_count)}, "Reset"), + idom.html.button({"onClick": lambda event: set_count(increment)}, "+"), + idom.html.button({"onClick": lambda event: set_count(decrement)}, "-"), ) diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index 6a740dae6..885fa4828 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -7,4 +7,3 @@ requests >=2 colorlog >=6 asgiref >=3 lxml >=4 -click >=8 diff --git a/scripts/fix_vdom_constructor_usage.py b/scripts/fix_vdom_constructor_usage.py deleted file mode 100644 index 97ee13d3a..000000000 --- a/scripts/fix_vdom_constructor_usage.py +++ /dev/null @@ -1,370 +0,0 @@ -from __future__ import annotations - -import ast -import re -import sys -from collections.abc import Sequence -from keyword import kwlist -from pathlib import Path -from textwrap import dedent, indent -from tokenize import COMMENT as COMMENT_TOKEN -from tokenize import generate_tokens -from typing import Iterator - -from idom import html - - -CAMEL_CASE_SUB_PATTERN = re.compile(r"(? None: - tree = ast.parse(source) - - changed: list[Sequence[ast.AST]] = [] - for parents, node in walk_with_parent(tree): - if isinstance(node, ast.Call): - func = node.func - match func: - case ast.Attribute(): - name = func.attr - case ast.Name(ctx=ast.Load()): - name = func.id - case _: - name = "" - if hasattr(html, name): - match node.args: - case [ast.Dict(keys, values), *_]: - new_kwargs = list(node.keywords) - for k, v in zip(keys, values): - if isinstance(k, ast.Constant) and isinstance(k.value, str): - new_kwargs.append( - ast.keyword(arg=conv_attr_name(k.value), value=v) - ) - else: - new_kwargs = [ast.keyword(arg=None, value=node.args[0])] - break - node.args = node.args[1:] - node.keywords = new_kwargs - changed.append((node, *parents)) - case [ - ast.Call( - func=ast.Name(id="dict", ctx=ast.Load()), - args=args, - keywords=kwargs, - ), - *_, - ]: - new_kwargs = [ - *[ast.keyword(arg=None, value=a) for a in args], - *node.keywords, - ] - for kw in kwargs: - if kw.arg is not None: - new_kwargs.append( - ast.keyword( - arg=conv_attr_name(kw.arg), value=kw.value - ) - ) - else: - new_kwargs.append(kw) - node.args = node.args[1:] - node.keywords = new_kwargs - changed.append((node, *parents)) - - case _: - pass - - if not changed: - return - - ast.fix_missing_locations(tree) - - lines = source.split("\n") - - # find closest parent nodes that should be re-written - nodes_to_unparse: list[ast.AST] = [] - for node_lineage in changed: - origin_node = node_lineage[0] - for i in range(len(node_lineage) - 1): - current_node, next_node = node_lineage[i : i + 2] - if ( - not hasattr(next_node, "lineno") - or next_node.lineno < origin_node.lineno - or isinstance(next_node, (ast.ClassDef, ast.FunctionDef)) - ): - nodes_to_unparse.append(current_node) - break - else: - raise RuntimeError("Failed to change code") - - # check if an nodes to rewrite contain eachother, pick outermost nodes - current_outermost_node, *sorted_nodes_to_unparse = list( - sorted(nodes_to_unparse, key=lambda n: n.lineno) - ) - outermost_nodes_to_unparse = [current_outermost_node] - for node in sorted_nodes_to_unparse: - if node.lineno > current_outermost_node.end_lineno: - current_outermost_node = node - outermost_nodes_to_unparse.append(node) - - moved_comment_lines_from_end: list[int] = [] - # now actually rewrite these nodes (in reverse to avoid changes earlier in file) - for node in reversed(outermost_nodes_to_unparse): - # make a best effort to preserve any comments that we're going to overwrite - comments = find_comments(lines[node.lineno - 1 : node.end_lineno]) - - # there may be some content just before and after the content we're re-writing - before_replacement = lines[node.lineno - 1][: node.col_offset].lstrip() - - if node.end_lineno is not None and node.end_col_offset is not None: - after_replacement = lines[node.end_lineno - 1][ - node.end_col_offset : - ].strip() - else: - after_replacement = "" - - replacement = indent( - before_replacement - + "\n".join([*comments, ast.unparse(node)]) - + after_replacement, - " " * (node.col_offset - len(before_replacement)), - ) - - if node.end_lineno: - lines[node.lineno - 1 : node.end_lineno] = [replacement] - else: - lines[node.lineno - 1] = replacement - - if comments: - moved_comment_lines_from_end.append(len(lines) - node.lineno) - - for lineno_from_end in sorted(list(set(moved_comment_lines_from_end))): - print(f"Moved comments to {filename}:{len(lines) - lineno_from_end}") - - return "\n".join(lines) - - -def find_comments(lines: list[str]) -> list[str]: - iter_lines = iter(lines) - return [ - token - for token_type, token, _, _, _ in generate_tokens(lambda: next(iter_lines)) - if token_type == COMMENT_TOKEN - ] - - -def walk_with_parent( - node: ast.AST, parents: tuple[ast.AST, ...] = () -) -> Iterator[tuple[tuple[ast.AST, ...], ast.AST]]: - parents = (node,) + parents - for child in ast.iter_child_nodes(node): - yield parents, child - yield from walk_with_parent(child, parents) - - -def conv_attr_name(name: str) -> str: - new_name = CAMEL_CASE_SUB_PATTERN.sub("_", name).replace("-", "_").lower() - return f"{new_name}_" if new_name in kwlist else new_name - - -def run_tests(): - cases = [ - # simple conversions - ( - 'html.div({"className": "test"})', - "html.div(class_name='test')", - ), - ( - 'html.div({class_name: "test", **other})', - "html.div(**{class_name: 'test', **other})", - ), - ( - 'html.div(dict(other, className="test"))', - "html.div(**other, class_name='test')", - ), - ( - 'html.div({"className": "outer"}, html.div({"className": "inner"}))', - "html.div(html.div(class_name='inner'), class_name='outer')", - ), - ( - 'html.div({"className": "outer"}, html.div({"className": "inner"}))', - "html.div(html.div(class_name='inner'), class_name='outer')", - ), - ( - '["before", html.div({"className": "test"}), "after"]', - "['before', html.div(class_name='test'), 'after']", - ), - ( - """ - html.div( - {"className": "outer"}, - html.div({"className": "inner"}), - html.div({"className": "inner"}), - ) - """, - "html.div(html.div(class_name='inner'), html.div(class_name='inner'), class_name='outer')", - ), - ( - 'html.div(dict(className="test"))', - "html.div(class_name='test')", - ), - # when to not attempt conversion - ( - 'html.div(ignore, {"className": "test"})', - None, - ), - # avoid unnecessary changes - ( - """ - def my_function(): - x = 1 # some comment - return html.div({"className": "test"}) - """, - """ - def my_function(): - x = 1 # some comment - return html.div(class_name='test') - """, - ), - ( - """ - if condition: - # some comment - dom = html.div({"className": "test"}) - """, - """ - if condition: - # some comment - dom = html.div(class_name='test') - """, - ), - ( - """ - [ - html.div({"className": "test"}), - html.div({"className": "test"}), - ] - """, - """ - [ - html.div(class_name='test'), - html.div(class_name='test'), - ] - """, - ), - ( - """ - @deco( - html.div({"className": "test"}), - html.div({"className": "test"}), - ) - def func(): - # comment - x = [ - 1 - ] - """, - """ - @deco( - html.div(class_name='test'), - html.div(class_name='test'), - ) - def func(): - # comment - x = [ - 1 - ] - """, - ), - ( - """ - @deco(html.div({"className": "test"}), html.div({"className": "test"})) - def func(): - # comment - x = [ - 1 - ] - """, - """ - @deco(html.div(class_name='test'), html.div(class_name='test')) - def func(): - # comment - x = [ - 1 - ] - """, - ), - ( - """ - ( - result - if condition - else html.div({"className": "test"}) - ) - """, - """ - ( - result - if condition - else html.div(class_name='test') - ) - """, - ), - # best effort to preserve comments - ( - """ - x = 1 - html.div( - # comment 1 - {"className": "outer"}, - # comment 2 - html.div({"className": "inner"}), - ) - """, - """ - x = 1 - # comment 1 - # comment 2 - html.div(html.div(class_name='inner'), class_name='outer') - """, - ), - ] - - for source, expected in cases: - actual = update_vdom_constructor_usages(dedent(source).strip(), "test.py") - if isinstance(expected, str): - expected = dedent(expected).strip() - if actual != expected: - print(TEST_OUTPUT_TEMPLATE.format(actual=actual, expected=expected)) - return False - - return True - - -if __name__ == "__main__": - argv = sys.argv[1:] - - if not argv: - print("Running tests...") - result = run_tests() - print("Success" if result else "Failed") - sys.exit(0 if result else 0) - - for pattern in argv: - for file in Path.cwd().glob(pattern): - result = update_vdom_constructor_usages( - source=file.read_text(), - filename=str(file), - ) - if result is not None: - file.write_text(result) diff --git a/src/client/packages/idom-client-react/src/element-utils.js b/src/client/packages/idom-client-react/src/element-utils.js index 334ca9019..01610804e 100644 --- a/src/client/packages/idom-client-react/src/element-utils.js +++ b/src/client/packages/idom-client-react/src/element-utils.js @@ -64,7 +64,7 @@ function normalizeAttribute([key, value]) { key.startsWith("aria_") || DASHED_HTML_ATTRS.includes(key) ) { - normKey = key.replace("_", "-"); + normKey = key.replaceAll("_", "-"); } else { normKey = snakeToCamel(key); } diff --git a/src/client/public/assets/idom-logo-square-small.svg b/src/client/public/assets/idom-logo-square-small.svg deleted file mode 100644 index eb36c7b11..000000000 --- a/src/client/public/assets/idom-logo-square-small.svg +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - diff --git a/src/idom/_warnings.py b/src/idom/_warnings.py index 827b66be0..3b49528e0 100644 --- a/src/idom/_warnings.py +++ b/src/idom/_warnings.py @@ -1,7 +1,7 @@ from functools import wraps from inspect import currentframe from types import FrameType -from typing import Any, Iterator +from typing import TYPE_CHECKING, Any, Iterator from warnings import warn as _warn @@ -11,6 +11,10 @@ def warn(*args: Any, **kwargs: Any) -> Any: _warn(*args, stacklevel=_frame_depth_in_module() + 1, **kwargs) # type: ignore +if TYPE_CHECKING: + warn = _warn + + def _frame_depth_in_module() -> int: depth = 0 for frame in _iter_frames(2): diff --git a/src/idom/backend/_common.py b/src/idom/backend/_common.py index f1db24818..dd7916353 100644 --- a/src/idom/backend/_common.py +++ b/src/idom/backend/_common.py @@ -113,9 +113,11 @@ class CommonOptions: head: Sequence[VdomDict] | VdomDict | str = ( html.title("IDOM"), html.link( - rel="icon", - href="_idom/assets/idom-logo-square-small.svg", - type="image/svg+xml", + { + "rel": "icon", + "href": "_idom/assets/idom-logo-square-small.svg", + "type": "image/svg+xml", + } ), ) """Add elements to the ```` of the application. diff --git a/src/idom/backend/tornado.py b/src/idom/backend/tornado.py index ca085fee8..dfa54eb5c 100644 --- a/src/idom/backend/tornado.py +++ b/src/idom/backend/tornado.py @@ -4,7 +4,7 @@ import json from asyncio import Queue as AsyncQueue from asyncio.futures import Future -from typing import Any, List, Tuple, Type, Union +from typing import Any from urllib.parse import urljoin from tornado.httpserver import HTTPServer @@ -14,6 +14,7 @@ from tornado.web import Application, RequestHandler, StaticFileHandler from tornado.websocket import WebSocketHandler from tornado.wsgi import WSGIContainer +from typing_extensions import TypeAlias from idom.backend.types import Connection, Location from idom.config import IDOM_WEB_MODULES_DIR @@ -107,7 +108,7 @@ def use_connection() -> Connection[HTTPServerRequest]: return conn -_RouteHandlerSpecs = List[Tuple[str, Type[RequestHandler], Any]] +_RouteHandlerSpecs: TypeAlias = "list[tuple[str, type[RequestHandler], Any]]" def _setup_common_routes(options: Options) -> _RouteHandlerSpecs: @@ -133,7 +134,7 @@ def _setup_common_routes(options: Options) -> _RouteHandlerSpecs: def _add_handler( app: Application, options: Options, handlers: _RouteHandlerSpecs ) -> None: - prefixed_handlers: List[Any] = [ + prefixed_handlers: list[Any] = [ (urljoin(options.url_prefix, route_pattern),) + tuple(handler_info) for route_pattern, *handler_info in handlers ] @@ -213,7 +214,7 @@ async def recv() -> Any: ) ) - async def on_message(self, message: Union[str, bytes]) -> None: + async def on_message(self, message: str | bytes) -> None: await self._message_queue.put( message if isinstance(message, str) else message.decode() ) diff --git a/src/idom/core/component.py b/src/idom/core/component.py index a7d4248e3..ff4e4c655 100644 --- a/src/idom/core/component.py +++ b/src/idom/core/component.py @@ -2,7 +2,7 @@ import inspect from functools import wraps -from typing import Any, Callable, Optional +from typing import Any, Callable, Dict, Optional, Tuple from .types import ComponentType, VdomDict @@ -41,8 +41,8 @@ def __init__( self, function: Callable[..., ComponentType | VdomDict | str | None], key: Optional[Any], - args: tuple[Any, ...], - kwargs: dict[str, Any], + args: Tuple[Any, ...], + kwargs: Dict[str, Any], sig: inspect.Signature, ) -> None: self.key = key diff --git a/src/idom/core/hooks.py b/src/idom/core/hooks.py index 2a694d98a..251374f02 100644 --- a/src/idom/core/hooks.py +++ b/src/idom/core/hooks.py @@ -10,23 +10,19 @@ Callable, Generic, NewType, - Optional, Sequence, - Tuple, TypeVar, - Union, cast, overload, ) -from typing_extensions import Protocol +from typing_extensions import Protocol, TypeAlias from idom.config import IDOM_DEBUG_MODE from idom.utils import Ref from ._thread_local import ThreadLocal from .types import ComponentType, Key, State, VdomDict -from .vdom import vdom if not TYPE_CHECKING: @@ -79,7 +75,7 @@ class _CurrentState(Generic[_Type]): def __init__( self, - initial_value: Union[_Type, Callable[[], _Type]], + initial_value: _Type | Callable[[], _Type], ) -> None: if callable(initial_value): self.value = initial_value() @@ -88,7 +84,7 @@ def __init__( hook = current_hook() - def dispatch(new: Union[_Type, Callable[[_Type], _Type]]) -> None: + def dispatch(new: _Type | Callable[[_Type], _Type]) -> None: if callable(new): next_value = new(self.value) else: @@ -100,10 +96,10 @@ def dispatch(new: Union[_Type, Callable[[_Type], _Type]]) -> None: self.dispatch = dispatch -_EffectCleanFunc = Callable[[], None] -_SyncEffectFunc = Callable[[], Optional[_EffectCleanFunc]] -_AsyncEffectFunc = Callable[[], Awaitable[Optional[_EffectCleanFunc]]] -_EffectApplyFunc = Union[_SyncEffectFunc, _AsyncEffectFunc] +_EffectCleanFunc: TypeAlias = "Callable[[], None]" +_SyncEffectFunc: TypeAlias = "Callable[[], _EffectCleanFunc | None]" +_AsyncEffectFunc: TypeAlias = "Callable[[], Awaitable[_EffectCleanFunc | None]]" +_EffectApplyFunc: TypeAlias = "_SyncEffectFunc | _AsyncEffectFunc" @overload @@ -123,9 +119,9 @@ def use_effect( def use_effect( - function: Optional[_EffectApplyFunc] = None, + function: _EffectApplyFunc | None = None, dependencies: Sequence[Any] | ellipsis | None = ..., -) -> Optional[Callable[[_EffectApplyFunc], None]]: +) -> Callable[[_EffectApplyFunc], None] | None: """See the full :ref:`Use Effect` docs for details Parameters: @@ -144,7 +140,7 @@ def use_effect( dependencies = _try_to_infer_closure_values(function, dependencies) memoize = use_memo(dependencies=dependencies) - last_clean_callback: Ref[Optional[_EffectCleanFunc]] = use_ref(None) + last_clean_callback: Ref[_EffectCleanFunc | None] = use_ref(None) def add_effect(function: _EffectApplyFunc) -> None: if not asyncio.iscoroutinefunction(function): @@ -152,7 +148,7 @@ def add_effect(function: _EffectApplyFunc) -> None: else: async_function = cast(_AsyncEffectFunc, function) - def sync_function() -> Optional[_EffectCleanFunc]: + def sync_function() -> _EffectCleanFunc | None: future = asyncio.ensure_future(async_function()) def clean_future() -> None: @@ -281,7 +277,7 @@ def __init__( def render(self) -> VdomDict: current_hook().set_context_provider(self) - return vdom("", *self.children) + return {"tagName": "", "children": self.children} def __repr__(self) -> str: return f"{type(self).__name__}({self.type})" @@ -293,7 +289,7 @@ def __repr__(self) -> str: def use_reducer( reducer: Callable[[_Type, _ActionType], _Type], initial_value: _Type, -) -> Tuple[_Type, Callable[[_ActionType], None]]: +) -> tuple[_Type, Callable[[_ActionType], None]]: """See the full :ref:`Use Reducer` docs for details Parameters: @@ -340,9 +336,9 @@ def use_callback( def use_callback( - function: Optional[_CallbackFunc] = None, + function: _CallbackFunc | None = None, dependencies: Sequence[Any] | ellipsis | None = ..., -) -> Union[_CallbackFunc, Callable[[_CallbackFunc], _CallbackFunc]]: +) -> _CallbackFunc | Callable[[_CallbackFunc], _CallbackFunc]: """See the full :ref:`Use Callback` docs for details Parameters: @@ -393,9 +389,9 @@ def use_memo( def use_memo( - function: Optional[Callable[[], _Type]] = None, + function: Callable[[], _Type] | None = None, dependencies: Sequence[Any] | ellipsis | None = ..., -) -> Union[_Type, Callable[[Callable[[], _Type]], _Type]]: +) -> _Type | Callable[[Callable[[], _Type]], _Type]: """See the full :ref:`Use Memo` docs for details Parameters: @@ -619,7 +615,7 @@ def __init__( self._is_rendering = False self._rendered_atleast_once = False self._current_state_index = 0 - self._state: Tuple[Any, ...] = () + self._state: tuple[Any, ...] = () self._event_effects: dict[EffectType, list[Callable[[], None]]] = { COMPONENT_DID_RENDER_EFFECT: [], LAYOUT_DID_RENDER_EFFECT: [], diff --git a/src/idom/core/types.py b/src/idom/core/types.py index cd266be95..e9ec39c1a 100644 --- a/src/idom/core/types.py +++ b/src/idom/core/types.py @@ -2,6 +2,7 @@ import sys from collections import namedtuple +from collections.abc import Sequence from types import TracebackType from typing import ( TYPE_CHECKING, @@ -10,11 +11,8 @@ Generic, Mapping, NamedTuple, - Optional, - Sequence, Type, TypeVar, - Union, overload, ) @@ -41,7 +39,7 @@ class State(NamedTuple, Generic[_Type]): """The root component should be constructed by a function accepting no arguments.""" -Key = Union[str, int] +Key: TypeAlias = "str | int" _OwnType = TypeVar("_OwnType") @@ -60,13 +58,10 @@ class ComponentType(Protocol): This is used to see if two component instances share the same definition. """ - def render(self) -> RenderResult: + def render(self) -> VdomDict | ComponentType | str | None: """Render the component's view model.""" -RenderResult = Union["VdomDict", ComponentType, str, None] - - _Render = TypeVar("_Render", covariant=True) _Event = TypeVar("_Event", contravariant=True) @@ -89,17 +84,17 @@ async def __aexit__( exc_type: Type[Exception], exc_value: Exception, traceback: TracebackType, - ) -> Optional[bool]: + ) -> bool | None: """Clean up the view after its final render""" VdomAttributes = Mapping[str, Any] """Describes the attributes of a :class:`VdomDict`""" -VdomChild = Union[ComponentType, "VdomDict", str] +VdomChild: TypeAlias = "ComponentType | VdomDict | str" """A single child element of a :class:`VdomDict`""" -VdomChildren = Sequence[VdomChild] +VdomChildren: TypeAlias = "Sequence[VdomChild] | VdomChild" """Describes a series of :class:`VdomChild` elements""" @@ -108,7 +103,10 @@ class _VdomDictOptional(TypedDict, total=False): children: Sequence[ # recursive types are not allowed yet: # https://github.com/python/mypy/issues/731 - Union[ComponentType, dict[str, Any], str, Any] + ComponentType + | dict[str, Any] + | str + | Any ] attributes: VdomAttributes eventHandlers: EventHandlerDict # noqa @@ -185,7 +183,7 @@ class EventHandlerType(Protocol): function: EventHandlerFunc """A coroutine which can respond to an event and its data""" - target: Optional[str] + target: str | None """Typically left as ``None`` except when a static target is useful. When testing, it may be useful to specify a static target ID so events can be @@ -201,27 +199,16 @@ class VdomDictConstructor(Protocol): """Standard function for constructing a :class:`VdomDict`""" @overload - def __call__( - self, - *children: VdomChild | VdomChildren, - key: Key | None = None, - **attributes: Any, - ) -> VdomDict: + def __call__(self, attributes: VdomAttributes, *children: VdomChildren) -> VdomDict: ... @overload - def __call__( - self, - *children: VdomChild | VdomChildren, - **attributes: Any, - ) -> VdomDict: + def __call__(self, *children: VdomChildren) -> VdomDict: ... + @overload def __call__( - self, - *children: VdomChild | VdomChildren, - key: Key | None = None, - **attributes: Any, + self, *attributes_and_children: VdomAttributes | VdomChildren ) -> VdomDict: ... diff --git a/src/idom/core/vdom.py b/src/idom/core/vdom.py index 2b1062ce3..998c4ff52 100644 --- a/src/idom/core/vdom.py +++ b/src/idom/core/vdom.py @@ -1,25 +1,19 @@ from __future__ import annotations import logging -from typing import Any, DefaultDict, Mapping, cast +from typing import Any, Mapping, Sequence, cast, overload from fastjsonschema import compile as compile_json_schema -from typing_extensions import TypeGuard from idom._warnings import warn from idom.config import IDOM_DEBUG_MODE -from idom.core.events import ( - EventHandler, - merge_event_handlers, - to_event_handler_function, -) +from idom.core.events import EventHandler, to_event_handler_function from idom.core.types import ( ComponentType, EventHandlerDict, EventHandlerType, ImportSourceDict, - Key, - VdomChild, + VdomAttributes, VdomChildren, VdomDict, VdomDictConstructor, @@ -131,11 +125,20 @@ def is_vdom(value: Any) -> bool: ) +@overload +def vdom(tag: str, *children: VdomChildren) -> VdomDict: + ... + + +@overload +def vdom(tag: str, attributes: VdomAttributes, *children: VdomChildren) -> VdomDict: + ... + + def vdom( tag: str, - *children: VdomChild | VdomChildren, - key: Key | None = None, - **attributes: Any, + *attributes_and_children: Any, + **kwargs: Any, ) -> VdomDict: """A helper function for creating VDOM elements. @@ -157,63 +160,58 @@ def vdom( (subject to change) specifies javascript that, when evaluated returns a React component. """ - model: VdomDict = {"tagName": tag} - - flattened_children: list[VdomChild] = [] - for child in children: - if isinstance(child, dict) and "tagName" not in child: # pragma: no cover + if kwargs: # pragma: no cover + if "key" in kwargs: + if attributes_and_children: + maybe_attributes, *children = attributes_and_children + if _is_attributes(maybe_attributes): + attributes_and_children = ( + {**maybe_attributes, "key": kwargs.pop("key")}, + *children, + ) + else: + attributes_and_children = ( + {"key": kwargs.pop("key")}, + maybe_attributes, + *children, + ) + else: + attributes_and_children = ({"key": kwargs.pop("key")},) warn( - ( - "Element constructor signatures have changed! This will be an error " - "in a future release. All element constructors now have the " - "following usage where attributes may be snake_case keyword " - "arguments: " - "\n\n" - ">>> html.div(*children, key=key, **attributes) " - "\n\n" - "A CLI tool for automatically updating code to the latest API has " - "been provided with this release of IDOM (e.g. 'idom " - "update-html-usages'). However, it may not resolve all issues " - "arrising from this change. Start a discussion if you need help " - "transitioning to this new interface: " - "https://github.com/idom-team/idom/discussions/new?category=question" - ), + "An element's 'key' must be declared in an attribute dict instead " + "of as a keyword argument. This will error in a future version.", DeprecationWarning, ) - attributes.update(child) - if _is_single_child(child): - flattened_children.append(child) - else: - # FIXME: Types do not narrow in negative case of TypeGaurd - # This cannot be fixed until there is some sort of "StrictTypeGuard". - # See: https://github.com/python/typing/discussions/1013 - flattened_children.extend(child) # type: ignore + if kwargs: + raise ValueError(f"Extra keyword arguments {kwargs}") + + model: VdomDict = {"tagName": tag} + + if not attributes_and_children: + return model + + attributes, children = separate_attributes_and_children(attributes_and_children) + key = attributes.pop("key", None) attributes, event_handlers = separate_attributes_and_event_handlers(attributes) if attributes: model["attributes"] = attributes - if flattened_children: - model["children"] = flattened_children + if children: + model["children"] = children + + if key: + model["key"] = key if event_handlers: model["eventHandlers"] = event_handlers - if key is not None: - model["key"] = key - return model -def with_import_source(element: VdomDict, import_source: ImportSourceDict) -> VdomDict: - return {**element, "importSource": import_source} # type: ignore - - def make_vdom_constructor( - tag: str, - allow_children: bool = True, - import_source: ImportSourceDict | None = None, + tag: str, allow_children: bool = True, import_source: ImportSourceDict | None = None ) -> VdomDictConstructor: """Return a constructor for VDOM dictionaries with the given tag name. @@ -221,18 +219,12 @@ def make_vdom_constructor( first ``tag`` argument. """ - def constructor( - *children: VdomChild | VdomChildren, - key: Key | None = None, - **attributes: Any, - ) -> VdomDict: - if not allow_children and children: + def constructor(*attributes_and_children: Any, **kwargs: Any) -> VdomDict: + model = vdom(tag, *attributes_and_children, **kwargs) + if not allow_children and "children" in model: raise TypeError(f"{tag!r} nodes cannot have children.") - - model = vdom(tag, *children, key=key, **attributes) - if import_source is not None: - model = with_import_source(model, import_source) - + if import_source: + model["importSource"] = import_source return model # replicate common function attributes @@ -248,14 +240,38 @@ def constructor( constructor.__module__ = module_name constructor.__qualname__ = f"{module_name}.{tag}" - return constructor + return cast(VdomDictConstructor, constructor) + + +def separate_attributes_and_children( + values: Sequence[Any], +) -> tuple[dict[str, Any], list[Any]]: + if not values: + return {}, [] + + attributes: dict[str, Any] + children_or_iterables: Sequence[Any] + if _is_attributes(values[0]): + attributes, *children_or_iterables = values + else: + attributes = {} + children_or_iterables = values + + children: list[Any] = [] + for child in children_or_iterables: + if _is_single_child(child): + children.append(child) + else: + children.extend(child) + + return attributes, children def separate_attributes_and_event_handlers( attributes: Mapping[str, Any] ) -> tuple[dict[str, Any], EventHandlerDict]: separated_attributes = {} - separated_handlers: DefaultDict[str, list[EventHandlerType]] = DefaultDict(list) + separated_event_handlers: dict[str, EventHandlerType] = {} for k, v in attributes.items(): handler: EventHandlerType @@ -273,16 +289,16 @@ def separate_attributes_and_event_handlers( separated_attributes[k] = v continue - separated_handlers[k].append(handler) + separated_event_handlers[k] = handler + + return separated_attributes, {k: h for k, h in separated_event_handlers.items()} - flat_event_handlers_dict = { - k: merge_event_handlers(h) for k, h in separated_handlers.items() - } - return separated_attributes, flat_event_handlers_dict +def _is_attributes(value: Any) -> bool: + return isinstance(value, Mapping) and "tagName" not in value -def _is_single_child(value: Any) -> TypeGuard[VdomChild]: +def _is_single_child(value: Any) -> bool: if isinstance(value, (str, Mapping)) or not hasattr(value, "__iter__"): return True if IDOM_DEBUG_MODE.current: diff --git a/src/idom/html.py b/src/idom/html.py index 8b4b3edfd..bd99df2cd 100644 --- a/src/idom/html.py +++ b/src/idom/html.py @@ -157,10 +157,10 @@ from __future__ import annotations -from typing import Any +from typing import Any, Mapping from idom.core.types import Key, VdomDict -from idom.core.vdom import make_vdom_constructor, vdom +from idom.core.vdom import make_vdom_constructor, separate_attributes_and_children __all__ = ( @@ -280,7 +280,18 @@ def _(*children: Any, key: Key | None = None) -> VdomDict: """An HTML fragment - this element will not appear in the DOM""" - return vdom("", *children, key=key) + attributes, coalesced_children = separate_attributes_and_children(children) + if attributes: + raise TypeError("Fragments cannot have attributes") + model: VdomDict = {"tagName": ""} + + if coalesced_children: + model["children"] = coalesced_children + + if key is not None: + model["key"] = key + + return model # Dcument metadata @@ -378,7 +389,10 @@ def _(*children: Any, key: Key | None = None) -> VdomDict: noscript = make_vdom_constructor("noscript") -def script(*children: str, key: Key | None = None, **attributes: Any) -> VdomDict: +def script( + *attributes_and_children: Mapping[str, Any] | str, + key: str | int | None = None, +) -> VdomDict: """Create a new `<{script}> `__ element. This behaves slightly differently than a normal script element in that it may be run @@ -392,20 +406,29 @@ def script(*children: str, key: Key | None = None, **attributes: Any) -> VdomDic function that is called when the script element is removed from the tree, or when the script content changes. """ + model: VdomDict = {"tagName": "script"} + + attributes, children = separate_attributes_and_children(attributes_and_children) + if children: if len(children) > 1: raise ValueError("'script' nodes may have, at most, one child.") elif not isinstance(children[0], str): raise ValueError("The child of a 'script' must be a string.") else: + model["children"] = children if key is None: key = children[0] if attributes: + model["attributes"] = attributes if key is None and not children and "src" in attributes: key = attributes["src"] - return vdom("script", *children, key=key, **attributes) + if key is not None: + model["key"] = key + + return model # Demarcating edits diff --git a/src/idom/sample.py b/src/idom/sample.py index 45ff87076..908de34b7 100644 --- a/src/idom/sample.py +++ b/src/idom/sample.py @@ -8,12 +8,14 @@ @component def SampleApp() -> VdomDict: return html.div( + {"id": "sample", "style": {"padding": "15px"}}, html.h1("Sample Application"), html.p( "This is a basic application made with IDOM. Click ", - html.a("here", href="https://pypi.org/project/idom/", target="_blank"), + html.a( + {"href": "https://pypi.org/project/idom/", "target": "_blank"}, + "here", + ), " to learn more.", ), - id="sample", - style={"padding": "15px"}, ) diff --git a/src/idom/utils.py b/src/idom/utils.py index 14a27e27f..20e45cac5 100644 --- a/src/idom/utils.py +++ b/src/idom/utils.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from itertools import chain from typing import Any, Callable, Generic, Iterable, TypeVar, cast @@ -153,7 +154,7 @@ def _etree_to_vdom( vdom: VdomDict if hasattr(idom.html, node.tag): - vdom = getattr(idom.html, node.tag)(*children, key=key, **attributes) + vdom = getattr(idom.html, node.tag)(attributes, *children, key=key) else: vdom = {"tagName": node.tag} if children: @@ -232,7 +233,7 @@ def _mutate_vdom(vdom: VdomDict) -> None: # Convince type checker that it's safe to mutate attributes assert isinstance(vdom["attributes"], dict) - # Convert style attribute from str -> dict with snake case keys + # Convert style attribute from str -> dict with camelCase keys vdom["attributes"]["style"] = { key.strip().replace("-", "_"): value.strip() for key, value in ( @@ -285,20 +286,18 @@ def _vdom_attr_to_html_str(key: str, value: Any) -> tuple[str, str]: value = ";".join( # We lower only to normalize - CSS is case-insensitive: # https://www.w3.org/TR/css-fonts-3/#font-family-casing - f"{k.replace('_', '-').lower()}:{v}" + f"{_CAMEL_CASE_SUB_PATTERN.sub('-', k).lower()}:{v}" for k, v in value.items() ) elif ( # camel to data-* attributes - key.startswith("data_") + key.startswith("data") # camel to aria-* attributes - or key.startswith("aria_") + or key.startswith("aria") # handle special cases or key in _DASHED_HTML_ATTRS ): - key = key.replace("_", "-") - else: - key = key.replace("_", "") + key = _CAMEL_CASE_SUB_PATTERN.sub("-", key) assert not callable( value @@ -309,6 +308,9 @@ def _vdom_attr_to_html_str(key: str, value: Any) -> tuple[str, str]: return key.lower(), str(value) +# Pattern for delimitting camelCase names (e.g. camelCase to camel-case) +_CAMEL_CASE_SUB_PATTERN = re.compile(r"(? None: def module_from_string( name: str, content: str, - fallback: Optional[Any] = None, + fallback: Any | None = None, resolve_exports: bool | None = None, resolve_exports_depth: int = 5, unmount_before_update: bool = False, @@ -302,9 +302,9 @@ def module_from_string( class WebModule: source: str source_type: SourceType - default_fallback: Optional[Any] - export_names: Optional[Set[str]] - file: Optional[Path] + default_fallback: Any | None + export_names: set[str] | None + file: Path | None unmount_before_update: bool @@ -312,7 +312,7 @@ class WebModule: def export( web_module: WebModule, export_names: str, - fallback: Optional[Any] = ..., + fallback: Any | None = ..., allow_children: bool = ..., ) -> VdomDictConstructor: ... @@ -321,19 +321,19 @@ def export( @overload def export( web_module: WebModule, - export_names: Union[List[str], Tuple[str, ...]], - fallback: Optional[Any] = ..., + export_names: list[str] | tuple[str, ...], + fallback: Any | None = ..., allow_children: bool = ..., -) -> List[VdomDictConstructor]: +) -> list[VdomDictConstructor]: ... def export( web_module: WebModule, - export_names: Union[str, List[str], Tuple[str, ...]], - fallback: Optional[Any] = None, + export_names: str | list[str] | tuple[str, ...], + fallback: Any | None = None, allow_children: bool = True, -) -> Union[VdomDictConstructor, List[VdomDictConstructor]]: +) -> VdomDictConstructor | list[VdomDictConstructor]: """Return one or more VDOM constructors from a :class:`WebModule` Parameters: @@ -369,7 +369,7 @@ def export( def _make_export( web_module: WebModule, name: str, - fallback: Optional[Any], + fallback: Any | None, allow_children: bool, ) -> VdomDictConstructor: return make_vdom_constructor( diff --git a/src/idom/widgets.py b/src/idom/widgets.py index 6c829216c..2cf84a3b6 100644 --- a/src/idom/widgets.py +++ b/src/idom/widgets.py @@ -1,7 +1,7 @@ from __future__ import annotations from base64 import b64encode -from typing import TYPE_CHECKING, Any, Callable, Iterable, Tuple, TypeVar, Union +from typing import TYPE_CHECKING, Any, Callable, Sequence, Tuple, TypeVar, Union from typing_extensions import Protocol @@ -15,7 +15,7 @@ def image( format: str, value: Union[str, bytes] = "", - **attributes: Any, + attributes: dict[str, Any] | None = None, ) -> VdomDict: """Utility for constructing an image from a string or bytes @@ -32,14 +32,14 @@ def image( base64_value = b64encode(bytes_value).decode() src = f"data:image/{format};base64,{base64_value}" - return {"tagName": "img", "attributes": {"src": src, **attributes}} + return {"tagName": "img", "attributes": {"src": src, **(attributes or {})}} _Value = TypeVar("_Value") def use_linked_inputs( - attributes: Iterable[dict[str, Any]], + attributes: Sequence[dict[str, Any]], on_change: Callable[[_Value], None] = lambda value: None, cast: _CastFunc[_Value] = lambda value: value, initial_value: str = "", @@ -75,13 +75,7 @@ def sync_inputs(event: dict[str, Any]) -> None: inputs: list[VdomDict] = [] for attrs in attributes: - # we're going to mutate this so copy it - attrs = attrs.copy() - - key = attrs.pop("key", None) - attrs.update({"onChange": sync_inputs, "value": value}) - - inputs.append(html.input(key=key, **attrs)) + inputs.append(html.input({**attrs, "on_change": sync_inputs, "value": value})) return inputs diff --git a/tests/test_backend/test__common.py b/tests/test_backend/test__common.py index c7b68401b..3a3b648d5 100644 --- a/tests/test_backend/test__common.py +++ b/tests/test_backend/test__common.py @@ -44,7 +44,7 @@ def test_catch_unsafe_relative_path_traversal(tmp_path, bad_path): ), ( html.head( - html.meta(charset="utf-8"), + html.meta({"charset": "utf-8"}), html.title("example"), ), # we strip the head element @@ -52,14 +52,14 @@ def test_catch_unsafe_relative_path_traversal(tmp_path, bad_path): ), ( html._( - html.meta(charset="utf-8"), + html.meta({"charset": "utf-8"}), html.title("example"), ), 'example', ), ( [ - html.meta(charset="utf-8"), + html.meta({"charset": "utf-8"}), html.title("example"), ], 'example', diff --git a/tests/test_backend/test_all.py b/tests/test_backend/test_all.py index a6417580e..98036cb16 100644 --- a/tests/test_backend/test_all.py +++ b/tests/test_backend/test_all.py @@ -39,7 +39,7 @@ async def display(page, request): async def test_display_simple_hello_world(display: DisplayFixture): @idom.component def Hello(): - return idom.html.p(["Hello World"], id="hello") + return idom.html.p({"id": "hello"}, ["Hello World"]) await display.show(Hello) @@ -56,9 +56,11 @@ async def test_display_simple_click_counter(display: DisplayFixture): def Counter(): count, set_count = idom.hooks.use_state(0) return idom.html.button( + { + "id": "counter", + "onClick": lambda event: set_count(lambda old_count: old_count + 1), + }, f"Count: {count}", - id="counter", - on_click=lambda event: set_count(lambda old_count: old_count + 1), ) await display.show(Counter) @@ -83,7 +85,7 @@ async def test_use_connection(display: DisplayFixture): @idom.component def ShowScope(): conn.current = idom.use_connection() - return html.pre(str(conn.current), id="scope") + return html.pre({"id": "scope"}, str(conn.current)) await display.show(ShowScope) @@ -97,7 +99,7 @@ async def test_use_scope(display: DisplayFixture): @idom.component def ShowScope(): scope.current = idom.use_scope() - return html.pre(str(scope.current), id="scope") + return html.pre({"id": "scope"}, str(scope.current)) await display.show(ShowScope) @@ -145,7 +147,7 @@ async def test_use_request(display: DisplayFixture, hook_name): @idom.component def ShowRoute(): hook_val.current = hook() - return html.pre(str(hook_val.current), id="hook") + return html.pre({"id": "hook"}, str(hook_val.current)) await display.show(ShowRoute) diff --git a/tests/test_client.py b/tests/test_client.py index fae191078..2f1501e8b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -25,8 +25,8 @@ async def test_automatic_reconnect(browser: Browser): def SomeComponent(): count, incr_count = use_counter(0) return idom.html._( - idom.html.p("count", count, data_count=count, id="count"), - idom.html.button("incr", on_click=lambda e: incr_count(), id="incr"), + idom.html.p({"data_count": count, "id": "count"}, "count", count), + idom.html.button(dict(on_click=lambda e: incr_count(), id="incr"), "incr"), ) async with AsyncExitStack() as exit_stack: @@ -85,10 +85,12 @@ def ButtonWithChangingColor(): color_toggle, set_color_toggle = idom.hooks.use_state(True) color = "red" if color_toggle else "blue" return idom.html.button( + { + "id": "my-button", + "onClick": lambda event: set_color_toggle(not color_toggle), + "style": {"backgroundColor": color, "color": "white"}, + }, f"color: {color}", - id="my-button", - on_click=lambda event: set_color_toggle(not color_toggle), - style={"background_color": color, "color": "white"}, ) await display.show(ButtonWithChangingColor) @@ -124,7 +126,7 @@ async def handle_change(event): await asyncio.sleep(delay) set_value(event["target"]["value"]) - return idom.html.input(on_change=handle_change, id="test-input") + return idom.html.input({"onChange": handle_change, "id": "test-input"}) await display.show(SomeComponent) @@ -132,3 +134,27 @@ async def handle_change(event): await inp.type("hello", delay=DEFAULT_TYPE_DELAY) assert (await inp.evaluate("node => node.value")) == "hello" + + +async def test_snake_case_attributes(display: DisplayFixture): + @idom.component + def SomeComponent(): + return idom.html.h1( + { + "id": "my-title", + "style": {"background_color": "blue"}, + "class_name": "hello", + "data_some_thing": "some-data", + "aria_some_thing": "some-aria", + }, + "title with some attributes", + ) + + await display.show(SomeComponent) + + title = await display.page.wait_for_selector("#my-title") + + assert await title.get_attribute("class") == "hello" + assert await title.get_attribute("style") == "background-color: blue;" + assert await title.get_attribute("data-some-thing") == "some-data" + assert await title.get_attribute("aria-some-thing") == "some-aria" diff --git a/tests/test_core/test_component.py b/tests/test_core/test_component.py index 9c2513a03..28c8b00f2 100644 --- a/tests/test_core/test_component.py +++ b/tests/test_core/test_component.py @@ -35,11 +35,11 @@ def SimpleParamComponent(tag): async def test_component_with_var_args(): @idom.component def ComponentWithVarArgsAndKwargs(*args, **kwargs): - return idom.html.div(*args, **kwargs) + return idom.html.div(kwargs, args) - assert ComponentWithVarArgsAndKwargs("hello", "world", my_attr=1).render() == { + assert ComponentWithVarArgsAndKwargs("hello", "world", myAttr=1).render() == { "tagName": "div", - "attributes": {"my_attr": 1}, + "attributes": {"myAttr": 1}, "children": ["hello", "world"], } @@ -47,7 +47,7 @@ def ComponentWithVarArgsAndKwargs(*args, **kwargs): async def test_display_simple_hello_world(display: DisplayFixture): @idom.component def Hello(): - return idom.html.p(["Hello World"], id="hello") + return idom.html.p({"id": "hello"}, ["Hello World"]) await display.show(Hello) @@ -58,10 +58,10 @@ async def test_pre_tags_are_rendered_correctly(display: DisplayFixture): @idom.component def PreFormated(): return idom.html.pre( + {"id": "pre-form-test"}, idom.html.span("this", idom.html.span("is"), "some"), "pre-formated", " text", - id="pre-form-test", ) await display.show(PreFormated) diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index 3eee82e6c..89f1dfa4c 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -151,7 +151,7 @@ def Input(): async def on_key_down(value): pass - return idom.html.input(on_key_down=on_key_down, id="input") + return idom.html.input({"onKeyDown": on_key_down, "id": "input"}) await display.show(Input) @@ -170,9 +170,9 @@ async def on_click(event): set_clicked(True) if not clicked: - return idom.html.button(["Click Me!"], on_click=on_click, id="click") + return idom.html.button({"onClick": on_click, "id": "click"}, ["Click Me!"]) else: - return idom.html.p(["Complete"], id="complete") + return idom.html.p({"id": "complete"}, ["Complete"]) await display.show(Button) @@ -194,14 +194,26 @@ def outer_click_is_not_triggered(event): assert False outer = idom.html.div( + { + "style": { + "height": "35px", + "width": "35px", + "backgroundColor": "red", + }, + "onClick": outer_click_is_not_triggered, + "id": "outer", + }, idom.html.div( - style={"height": "30px", "width": "30px", "backgroundColor": "blue"}, - on_click=inner_click_no_op, - id="inner", + { + "style": { + "height": "30px", + "width": "30px", + "backgroundColor": "blue", + }, + "onClick": inner_click_no_op, + "id": "inner", + }, ), - style={"height": "35px", "width": "35px", "backgroundColor": "red"}, - on_click=outer_click_is_not_triggered, - id="outer", ) return outer diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index fd3d39c2e..91a00fe1d 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -181,14 +181,18 @@ def TestComponent(): render_count.current += 1 return idom.html.div( idom.html.button( + { + "id": "r_1", + "onClick": event_count_tracker(lambda event: set_state(r_1)), + }, "r_1", - id="r_1", - on_click=event_count_tracker(lambda event: set_state(r_1)), ), idom.html.button( + { + "id": "r_2", + "onClick": event_count_tracker(lambda event: set_state(r_2)), + }, "r_2", - id="r_2", - on_click=event_count_tracker(lambda event: set_state(r_2)), ), f"Last state: {'r_1' if state is r_1 else 'r_2'}", ) @@ -233,9 +237,9 @@ async def on_change(event): set_message(event["target"]["value"]) if message is None: - return idom.html.input(id="input", on_change=on_change) + return idom.html.input({"id": "input", "onChange": on_change}) else: - return idom.html.p(["Complete"], id="complete") + return idom.html.p({"id": "complete"}, ["Complete"]) await display.show(Input) @@ -257,9 +261,13 @@ def double_set_state(event): set_state_2(state_2 + 1) return idom.html.div( - idom.html.div(f"value is: {state_1}", id="first", data_value=state_1), - idom.html.div(f"value is: {state_2}", id="second", data_value=state_2), - idom.html.button("click me", id="button", on_click=double_set_state), + idom.html.div( + {"id": "first", "data-value": state_1}, f"value is: {state_1}" + ), + idom.html.div( + {"id": "second", "data-value": state_2}, f"value is: {state_2}" + ), + idom.html.button({"id": "button", "onClick": double_set_state}, "click me"), ) await display.show(SomeComponent) diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index 5a026bfea..f97a2db22 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -505,8 +505,10 @@ def bad_trigger(): raise ValueError("Called bad trigger") children = [ - idom.html.button("good", key="good", on_click=good_trigger, id="good"), - idom.html.button("bad", key="bad", on_click=bad_trigger, id="bad"), + idom.html.button( + {"onClick": good_trigger, "id": "good"}, "good", key="good" + ), + idom.html.button({"onClick": bad_trigger, "id": "bad"}, "bad", key="bad"), ] if reverse_children: @@ -563,7 +565,7 @@ def callback(): def callback(): raise ValueError("Called bad trigger") - return idom.html.button("good", on_click=callback, id="good") + return idom.html.button({"onClick": callback, "id": "good"}, "good") async with idom.Layout(RootComponent()) as layout: await layout.render() @@ -645,8 +647,8 @@ def HasEventHandlerAtRoot(): value, set_value = idom.hooks.use_state(False) set_value(not value) # trigger renders forever event_handler.current = weakref(set_value) - button = idom.html.button("state is: ", value, on_click=set_value) - event_handler.current = weakref(button["eventHandlers"]["on_click"].function) + button = idom.html.button({"onClick": set_value}, "state is: ", value) + event_handler.current = weakref(button["eventHandlers"]["onClick"].function) return button async with idom.Layout(HasEventHandlerAtRoot()) as layout: @@ -667,8 +669,8 @@ def HasNestedEventHandler(): value, set_value = idom.hooks.use_state(False) set_value(not value) # trigger renders forever event_handler.current = weakref(set_value) - button = idom.html.button("state is: ", value, on_click=set_value) - event_handler.current = weakref(button["eventHandlers"]["on_click"].function) + button = idom.html.button({"onClick": set_value}, "state is: ", value) + event_handler.current = weakref(button["eventHandlers"]["onClick"].function) return idom.html.div(idom.html.div(button)) async with idom.Layout(HasNestedEventHandler()) as layout: @@ -749,7 +751,7 @@ def ComponentWithBadEventHandler(): def raise_error(): raise Exception("bad event handler") - return idom.html.button(on_click=raise_error) + return idom.html.button({"onClick": raise_error}) with assert_idom_did_log(match_error="bad event handler"): async with idom.Layout(ComponentWithBadEventHandler()) as layout: @@ -845,7 +847,7 @@ def SomeComponent(): return idom.html.div( [ idom.html.div( - idom.html.input(on_change=lambda event: None), + idom.html.input({"onChange": lambda event: None}), key=str(i), ) for i in items @@ -904,14 +906,14 @@ def Root(): toggle, toggle_type.current = use_toggle(True) handler = element_static_handler.use(lambda: None) if toggle: - return html.div(html.button(on_event=handler)) + return html.div(html.button({"on_event": handler})) else: return html.div(SomeComponent()) @idom.component def SomeComponent(): handler = component_static_handler.use(lambda: None) - return html.button(on_another_event=handler) + return html.button({"onAnotherEvent": handler}) async with idom.Layout(Root()) as layout: await layout.render() @@ -994,7 +996,8 @@ def Parent(): state, set_state = use_state(0) return html.div( html.button( - "click me", on_click=set_child_key_num.use(lambda: set_state(state + 1)) + {"onClick": set_child_key_num.use(lambda: set_state(state + 1))}, + "click me", ), Child("some-key"), Child(f"key-{state}"), @@ -1067,7 +1070,7 @@ async def test_changing_event_handlers_in_the_next_render(): def Root(): event_name, set_event_name.current = use_state("first") return html.button( - **{event_name: event_handler.use(lambda: did_trigger.set_current(True))} + {event_name: event_handler.use(lambda: did_trigger.set_current(True))} ) async with Layout(Root()) as layout: diff --git a/tests/test_core/test_serve.py b/tests/test_core/test_serve.py index 94b403ebc..6798b5654 100644 --- a/tests/test_core/test_serve.py +++ b/tests/test_core/test_serve.py @@ -85,7 +85,7 @@ def Counter(): initial_value=0, ) handler = STATIC_EVENT_HANDLER.use(lambda: change_count(1)) - return idom.html.div(**{EVENT_NAME: handler}, count=count) + return idom.html.div({EVENT_NAME: handler, "count": count}) async def test_dispatch(): @@ -115,8 +115,8 @@ async def handle_event(): second_event_did_execute.set() return idom.html.div( - idom.html.button(on_click=block_forever), - idom.html.button(on_click=handle_event), + idom.html.button({"onClick": block_forever}), + idom.html.button({"onClick": handle_event}), ) send_queue = asyncio.Queue() diff --git a/tests/test_core/test_vdom.py b/tests/test_core/test_vdom.py index f9f99e5a8..b17028e38 100644 --- a/tests/test_core/test_vdom.py +++ b/tests/test_core/test_vdom.py @@ -7,12 +7,7 @@ from idom.config import IDOM_DEBUG_MODE from idom.core.events import EventHandler from idom.core.types import VdomDict -from idom.core.vdom import ( - is_vdom, - make_vdom_constructor, - validate_vdom_json, - with_import_source, -) +from idom.core.vdom import is_vdom, make_vdom_constructor, validate_vdom_json FAKE_EVENT_HANDLER = EventHandler(lambda data: None) @@ -41,7 +36,7 @@ def test_is_vdom(result, value): {"tagName": "div", "children": [{"tagName": "div"}]}, ), ( - idom.vdom("div", style={"backgroundColor": "red"}), + idom.vdom("div", {"style": {"backgroundColor": "red"}}), {"tagName": "div", "attributes": {"style": {"backgroundColor": "red"}}}, ), ( @@ -53,7 +48,7 @@ def test_is_vdom(result, value): }, ), ( - idom.vdom("div", on_event=FAKE_EVENT_HANDLER), + idom.vdom("div", {"on_event": FAKE_EVENT_HANDLER}), {"tagName": "div", "eventHandlers": FAKE_EVENT_HANDLER_DICT}, ), ( @@ -78,19 +73,6 @@ def test_is_vdom(result, value): idom.vdom("div", map(lambda x: x**2, [1, 2, 3])), {"tagName": "div", "children": [1, 4, 9]}, ), - ( - with_import_source( - idom.vdom("MyComponent"), - {"source": "./some-script.js", "fallback": "loading..."}, - ), - { - "tagName": "MyComponent", - "importSource": { - "source": "./some-script.js", - "fallback": "loading...", - }, - }, - ), ], ) def test_simple_node_construction(actual, expected): @@ -100,7 +82,7 @@ def test_simple_node_construction(actual, expected): async def test_callable_attributes_are_cast_to_event_handlers(): params_from_calls = [] - node = idom.vdom("div", on_event=lambda *args: params_from_calls.append(args)) + node = idom.vdom("div", {"on_event": lambda *args: params_from_calls.append(args)}) event_handlers = node.pop("eventHandlers") assert node == {"tagName": "div"} @@ -116,7 +98,7 @@ async def test_callable_attributes_are_cast_to_event_handlers(): def test_make_vdom_constructor(): elmt = make_vdom_constructor("some-tag") - assert elmt(elmt(), data=1) == { + assert elmt({"data": 1}, [elmt()]) == { "tagName": "some-tag", "children": [{"tagName": "some-tag"}], "attributes": {"data": 1}, @@ -234,13 +216,13 @@ def test_valid_vdom(value): r"data\.eventHandlers must be object", ), ( - {"tagName": "tag", "eventHandlers": {"onEvent": None}}, + {"tagName": "tag", "eventHandlers": {"on_event": None}}, r"data\.eventHandlers\.{data_key} must be object", ), ( { "tagName": "tag", - "eventHandlers": {"onEvent": {}}, + "eventHandlers": {"on_event": {}}, }, r"data\.eventHandlers\.{data_key}\ must contain \['target'\] properties", ), @@ -248,7 +230,7 @@ def test_valid_vdom(value): { "tagName": "tag", "eventHandlers": { - "onEvent": { + "on_event": { "target": "something", "preventDefault": None, } @@ -260,7 +242,7 @@ def test_valid_vdom(value): { "tagName": "tag", "eventHandlers": { - "onEvent": { + "on_event": { "target": "something", "stopPropagation": None, } diff --git a/tests/test_html.py b/tests/test_html.py index b296447a3..794a16474 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -13,7 +13,7 @@ async def test_script_mount_unmount(display: DisplayFixture): def Root(): is_mounted, toggle_is_mounted.current = use_toggle(True) return html.div( - html.div(id="mount-state", data_value=False), + html.div({"id": "mount-state", "data-value": False}), HasScript() if is_mounted else html.div(), ) @@ -53,8 +53,8 @@ async def test_script_re_run_on_content_change(display: DisplayFixture): def HasScript(): count, incr_count.current = use_counter(1) return html.div( - html.div(id="mount-count", data_value=0), - html.div(id="unmount-count", data_value=0), + html.div({"id": "mount-count", "data-value": 0}), + html.div({"id": "unmount-count", "data-value": 0}), html.script( f"""() => {{ const mountCountEl = document.getElementById("mount-count"); @@ -101,9 +101,11 @@ def HasScript(): return html.div() else: return html.div( - html.div(id="run-count", data_value=0), + html.div({"id": "run-count", "data-value": 0}), html.script( - src=f"/_idom/modules/{file_name_template.format(src_id=src_id)}" + { + "src": f"/_idom/modules/{file_name_template.format(src_id=src_id)}" + } ), ) @@ -149,5 +151,5 @@ def test_simple_fragment(): def test_fragment_can_have_no_attributes(): - with pytest.raises(TypeError): - html._(some_attribute=1) + with pytest.raises(TypeError, match="Fragments cannot have attributes"): + html._({"some-attribute": 1}) diff --git a/tests/test_testing.py b/tests/test_testing.py index 9b4941d4c..27afa980a 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -181,7 +181,7 @@ def make_next_count_constructor(count): def constructor(): count.current += 1 - return html.div(count.current, id=f"hotswap-{count.current}") + return html.div({"id": f"hotswap-{count.current}"}, count.current) return constructor @@ -192,7 +192,7 @@ def ButtonSwapsDivs(): async def on_click(event): mount(make_next_count_constructor(count)) - incr = html.button("incr", on_click=on_click, id="incr-button") + incr = html.button({"onClick": on_click, "id": "incr-button"}, "incr") mount, make_hostswap = _hotswap(update_on_change=True) mount(make_next_count_constructor(count)) diff --git a/tests/test_utils.py b/tests/test_utils.py index 3fc5ef2f0..c09f09f16 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -46,15 +46,7 @@ def test_ref_repr(): @pytest.mark.parametrize( "case", [ - { - "source": "
", - "model": {"tagName": "div"}, - }, - { - "source": "
", - # we don't touch attribute values - "model": {"tagName": "div", "attributes": {"some-attribute": "thing"}}, - }, + {"source": "
", "model": {"tagName": "div"}}, { "source": "
", "model": { @@ -213,15 +205,17 @@ def test_del_html_body_transform(): f"
{html_escape(str(SOME_OBJECT))}
", ), ( - html.div(some_attribute=SOME_OBJECT), + html.div({"someAttribute": SOME_OBJECT}), f'
', ), ( - html.div("hello", html.a("example", href="https://example.com"), "world"), + html.div( + "hello", html.a({"href": "https://example.com"}, "example"), "world" + ), '
helloexampleworld
', ), ( - html.button(on_click=lambda event: None), + html.button({"onClick": lambda event: None}), "", ), ( @@ -233,17 +227,17 @@ def test_del_html_body_transform(): "
hello
world", ), ( - html.div(style={"background_color": "blue", "margin_left": "10px"}), + html.div({"style": {"backgroundColor": "blue", "marginLeft": "10px"}}), '
', ), ( - html.div(style="background-color:blue;margin-left:10px"), + html.div({"style": "background-color:blue;margin-left:10px"}), '
', ), ( html._( html.div("hello"), - html.a("example", href="https://example.com"), + html.a({"href": "https://example.com"}, "example"), ), '
hello
example', ), @@ -251,14 +245,16 @@ def test_del_html_body_transform(): html.div( html._( html.div("hello"), - html.a("example", href="https://example.com"), + html.a({"href": "https://example.com"}, "example"), ), html.button(), ), '
hello
example
', ), ( - html.div(data_something=1, data_something_else=2, dataisnotdashed=3), + html.div( + {"dataSomething": 1, "dataSomethingElse": 2, "dataisnotdashed": 3} + ), '
', ), ], diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 1131ea87c..497b89787 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -32,7 +32,7 @@ async def test_that_js_module_unmount_is_called(display: DisplayFixture): @idom.component def ShowCurrentComponent(): current_component, set_current_component.current = idom.hooks.use_state( - lambda: SomeComponent(id="some-component", text="initial component") + lambda: SomeComponent({"id": "some-component", "text": "initial component"}) ) return current_component @@ -41,7 +41,7 @@ def ShowCurrentComponent(): await display.page.wait_for_selector("#some-component", state="attached") set_current_component.current( - idom.html.h1("some other component", id="some-other-component") + idom.html.h1({"id": "some-other-component"}, "some other component") ) # the new component has been displayed @@ -68,7 +68,7 @@ async def test_module_from_url(browser): @idom.component def ShowSimpleButton(): - return SimpleButton(id="my-button") + return SimpleButton({"id": "my-button"}) async with BackendFixture(app=app, implementation=sanic_implementation) as server: async with DisplayFixture(server, browser) as display: @@ -105,8 +105,7 @@ async def test_module_from_file(display: DisplayFixture): @idom.component def ShowSimpleButton(): return SimpleButton( - id="my-button", - on_click=lambda event: is_clicked.set_current(True), + {"id": "my-button", "onClick": lambda event: is_clicked.set_current(True)} ) await display.show(ShowSimpleButton) @@ -199,11 +198,11 @@ async def test_module_exports_multiple_components(display: DisplayFixture): ["Header1", "Header2"], ) - await display.show(lambda: Header1("My Header 1", id="my-h1")) + await display.show(lambda: Header1({"id": "my-h1"}, "My Header 1")) await display.page.wait_for_selector("#my-h1", state="attached") - await display.show(lambda: Header2("My Header 2", id="my-h2")) + await display.show(lambda: Header2({"id": "my-h2"}, "My Header 2")) await display.page.wait_for_selector("#my-h2", state="attached") @@ -216,9 +215,9 @@ async def test_imported_components_can_render_children(display: DisplayFixture): await display.show( lambda: Parent( - Child(index=1), - Child(index=2), - Child(index=3), + Child({"index": 1}), + Child({"index": 2}), + Child({"index": 3}), ) ) diff --git a/tests/test_widgets.py b/tests/test_widgets.py index dd5aa21ab..cd6f9b2c2 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -19,14 +19,14 @@ async def test_image_from_string(display: DisplayFixture): src = IMAGE_SRC_BYTES.decode() - await display.show(lambda: idom.widgets.image("svg", src, id="a-circle-1")) + await display.show(lambda: idom.widgets.image("svg", src, {"id": "a-circle-1"})) client_img = await display.page.wait_for_selector("#a-circle-1") assert BASE64_IMAGE_SRC in (await client_img.get_attribute("src")) async def test_image_from_bytes(display: DisplayFixture): src = IMAGE_SRC_BYTES - await display.show(lambda: idom.widgets.image("svg", src, id="a-circle-1")) + await display.show(lambda: idom.widgets.image("svg", src, {"id": "a-circle-1"})) client_img = await display.page.wait_for_selector("#a-circle-1") assert BASE64_IMAGE_SRC in (await client_img.get_attribute("src")) From b253e26e51ba00eb6e3286fdcbb95c093d3aa00a Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 12 Feb 2023 15:53:21 -0800 Subject: [PATCH 02/19] add key rewrite script --- src/idom/__main__.py | 4 +- ..._usages.py => rewrite_key_declarations.py} | 139 ++++++++------- ...es.py => test_rewrite_key_declarations.py} | 165 +++++++++--------- 3 files changed, 161 insertions(+), 147 deletions(-) rename src/idom/_console/{update_html_usages.py => rewrite_key_declarations.py} (66%) rename tests/test__console/{test_update_html_usages.py => test_rewrite_key_declarations.py} (50%) diff --git a/src/idom/__main__.py b/src/idom/__main__.py index d8d07aa66..30d9cc55e 100644 --- a/src/idom/__main__.py +++ b/src/idom/__main__.py @@ -1,7 +1,7 @@ import click import idom -from idom._console.update_html_usages import update_html_usages +from idom._console.rewrite_key_declarations import rewrite_key_declarations @click.group() @@ -10,7 +10,7 @@ def app() -> None: pass -app.add_command(update_html_usages) +app.add_command(rewrite_key_declarations) if __name__ == "__main__": diff --git a/src/idom/_console/update_html_usages.py b/src/idom/_console/rewrite_key_declarations.py similarity index 66% rename from src/idom/_console/update_html_usages.py rename to src/idom/_console/rewrite_key_declarations.py index f824df56f..25f90630c 100644 --- a/src/idom/_console/update_html_usages.py +++ b/src/idom/_console/rewrite_key_declarations.py @@ -5,7 +5,6 @@ import sys from collections.abc import Sequence from dataclasses import dataclass -from keyword import kwlist from pathlib import Path from textwrap import indent from tokenize import COMMENT as COMMENT_TOKEN @@ -22,7 +21,7 @@ @click.command() @click.argument("paths", nargs=-1, type=click.Path(exists=True)) -def update_html_usages(paths: list[str]) -> None: +def rewrite_key_declarations(paths: list[str]) -> None: """Rewrite files under the given paths using the new html element API. The old API required users to pass a dictionary of attributes to html element @@ -64,9 +63,21 @@ def update_html_usages(paths: list[str]) -> None: def generate_rewrite(file: Path, source: str) -> str | None: tree = ast.parse(source) + changed = find_nodes_to_change(tree) + if not changed: + log_could_not_rewrite(file, tree) + return None + + new = rewrite_changed_nodes(file, source, tree, changed) + log_could_not_rewrite(file, ast.parse(new)) + + return new + + +def find_nodes_to_change(tree: ast.AST) -> list[Sequence[ast.AST]]: changed: list[Sequence[ast.AST]] = [] for parents, node in walk_with_parent(tree): - if not isinstance(node, ast.Call): + if not (isinstance(node, ast.Call) and node.keywords): continue func = node.func @@ -77,34 +88,62 @@ def generate_rewrite(file: Path, source: str) -> str | None: else: continue + for kw in list(node.keywords): + if kw.arg == "key": + break + else: + continue + + maybe_attr_dict_node = None if name == "vdom": - if len(node.args) < 2: - continue - maybe_attr_dict_node = node.args[1] - # remove attr dict from new args - new_args = node.args[:1] + node.args[2:] + if len(node.args) == 1: + # vdom("tag") need to add attr dict + maybe_attr_dict_node = ast.Dict(keys=[], values=[]) + node.args.append(maybe_attr_dict_node) + elif isinstance(node.args[1], (ast.Constant, ast.JoinedStr)): + maybe_attr_dict_node = ast.Dict(keys=[], values=[]) + node.args.insert(1, maybe_attr_dict_node) + elif len(node.args) >= 2: + maybe_attr_dict_node = node.args[1] elif hasattr(html, name): if len(node.args) == 0: - continue - maybe_attr_dict_node = node.args[0] - # remove attr dict from new args - new_args = node.args[1:] + # vdom("tag") need to add attr dict + maybe_attr_dict_node = ast.Dict(keys=[], values=[]) + node.args.append(maybe_attr_dict_node) + elif isinstance(node.args[0], (ast.Constant, ast.JoinedStr)): + maybe_attr_dict_node = ast.Dict(keys=[], values=[]) + node.args.insert(0, maybe_attr_dict_node) + else: + maybe_attr_dict_node = node.args[0] + + if not maybe_attr_dict_node: + continue + + if isinstance(maybe_attr_dict_node, ast.Dict): + maybe_attr_dict_node.keys.append(ast.Constant("key")) + maybe_attr_dict_node.values.append(kw.value) + elif ( + isinstance(maybe_attr_dict_node, ast.Call) + and isinstance(maybe_attr_dict_node.func, ast.Name) + and maybe_attr_dict_node.func.id == "dict" + and isinstance(maybe_attr_dict_node.func.ctx, ast.Load) + ): + maybe_attr_dict_node.keywords.append(ast.keyword(arg="key", value=kw.value)) else: continue - new_keyword_info = extract_keywords(maybe_attr_dict_node) - if new_keyword_info is not None: - if new_keyword_info.replace: - node.keywords = new_keyword_info.keywords - else: - node.keywords.extend(new_keyword_info.keywords) + node.keywords.remove(kw) + changed.append((node, *parents)) - node.args = new_args - changed.append((node, *parents)) + return changed - if not changed: - return None +def rewrite_changed_nodes( + file: str, + source: str, + tree: ast.AST, + changed: list[Sequence[ast.AST]], +) -> str: ast.fix_missing_locations(tree) lines = source.split("\n") @@ -171,38 +210,25 @@ def generate_rewrite(file: Path, source: str) -> str | None: return "\n".join(lines) -def extract_keywords(node: ast.AST) -> KeywordInfo | None: - if isinstance(node, ast.Dict): - keywords: list[ast.keyword] = [] - for k, v in zip(node.keys, node.values): - if isinstance(k, ast.Constant) and isinstance(k.value, str): - if k.value == "tagName": - # this is a vdom dict declaration - return None - keywords.append(ast.keyword(arg=conv_attr_name(k.value), value=v)) - else: - return KeywordInfo( - replace=True, - keywords=[ast.keyword(arg=None, value=node)], - ) - return KeywordInfo(replace=False, keywords=keywords) - elif ( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id == "dict" - and isinstance(node.func.ctx, ast.Load) - ): - keywords = [ast.keyword(arg=None, value=a) for a in node.args] - for kw in node.keywords: - if kw.arg == "tagName": - # this is a vdom dict declaration - return None - if kw.arg is not None: - keywords.append(ast.keyword(arg=conv_attr_name(kw.arg), value=kw.value)) - else: - keywords.append(kw) - return KeywordInfo(replace=False, keywords=keywords) - return None +def log_could_not_rewrite(file: str, tree: ast.AST) -> None: + for node in ast.walk(tree): + if not (isinstance(node, ast.Call) and node.keywords): + continue + + func = node.func + if isinstance(func, ast.Attribute): + name = func.attr + elif isinstance(func, ast.Name): + name = func.id + else: + continue + + if ( + name == "vdom" + or hasattr(html, name) + and any(kw.arg == "key" for kw in node.keywords) + ): + click.echo(f"Unable to rewrite usage at {file}:{node.lineno}") def find_comments(lines: list[str]) -> list[str]: @@ -223,11 +249,6 @@ def walk_with_parent( yield from walk_with_parent(child, parents) -def conv_attr_name(name: str) -> str: - new_name = CAMEL_CASE_SUB_PATTERN.sub("_", name).replace("-", "_").lower() - return f"{new_name}_" if new_name in kwlist else new_name - - @dataclass class KeywordInfo: replace: bool diff --git a/tests/test__console/test_update_html_usages.py b/tests/test__console/test_rewrite_key_declarations.py similarity index 50% rename from tests/test__console/test_update_html_usages.py rename to tests/test__console/test_rewrite_key_declarations.py index 2b5bb34c4..39bd10e00 100644 --- a/tests/test__console/test_update_html_usages.py +++ b/tests/test__console/test_rewrite_key_declarations.py @@ -5,33 +5,38 @@ import pytest from click.testing import CliRunner -from idom._console.update_html_usages import generate_rewrite, update_html_usages +from idom._console.rewrite_key_declarations import ( + generate_rewrite, + rewrite_key_declarations, +) if sys.version_info < (3, 9): pytestmark = pytest.mark.skip(reason="ast.unparse is Python>=3.9") -def test_update_html_usages(tmp_path): +def test_rewrite_key_declarations(tmp_path): runner = CliRunner() tempfile: Path = tmp_path / "temp.py" - tempfile.write_text("html.div({'className': test})") + tempfile.write_text("html.div(key='test')") result = runner.invoke( - update_html_usages, + rewrite_key_declarations, args=[str(tmp_path)], catch_exceptions=False, ) assert result.exit_code == 0 - assert tempfile.read_text() == "html.div(class_name=test)" + assert tempfile.read_text() == "html.div({'key': 'test'})" -def test_update_html_usages_no_files(): +def test_rewrite_key_declarations_no_files(): runner = CliRunner() result = runner.invoke( - update_html_usages, args=["directory-does-no-exist"], catch_exceptions=False + rewrite_key_declarations, + args=["directory-does-no-exist"], + catch_exceptions=False, ) assert result.exit_code != 0 @@ -41,125 +46,81 @@ def test_update_html_usages_no_files(): "source, expected", [ ( - 'html.div({"className": "test"})', - "html.div(class_name='test')", - ), - ( - 'vdom("div", {"className": "test"})', - "vdom('div', class_name='test')", - ), - ( - 'html.div({variable: "test", **other, "key": value})', - "html.div(**{variable: 'test', **other, 'key': value})", - ), - ( - 'html.div(dict(other, className="test", **another))', - "html.div(**other, class_name='test', **another)", - ), - ( - 'html.div({"className": "outer"}, html.div({"className": "inner"}))', - "html.div(html.div(class_name='inner'), class_name='outer')", - ), - ( - 'html.div({"className": "outer"}, html.div({"className": "inner"}))', - "html.div(html.div(class_name='inner'), class_name='outer')", - ), - ( - '["before", html.div({"className": "test"}), "after"]', - "['before', html.div(class_name='test'), 'after']", + "html.div(key='test')", + "html.div({'key': 'test'})", ), ( - """ - html.div( - {"className": "outer"}, - html.div({"className": "inner"}), - html.div({"className": "inner"}), - ) - """, - "html.div(html.div(class_name='inner'), html.div(class_name='inner'), class_name='outer')", + "html.div('something', key='test')", + "html.div({'key': 'test'}, 'something')", ), ( - 'html.div(dict(className="test"))', - "html.div(class_name='test')", - ), - # when to not attempt conversion - ( - 'html.div(ignore, {"className": "test"})', - None, + "html.div({'some_attr': 1}, child_1, child_2, key='test')", + "html.div({'some_attr': 1, 'key': 'test'}, child_1, child_2)", ), ( - "html.div()", - None, + "vdom('div', key='test')", + "vdom('div', {'key': 'test'})", ), ( - 'html.vdom("div")', - None, + "vdom('div', 'something', key='test')", + "vdom('div', {'key': 'test'}, 'something')", ), ( - 'html.div({"tagName": "test"})', - None, + "vdom('div', {'some_attr': 1}, child_1, child_2, key='test')", + "vdom('div', {'some_attr': 1, 'key': 'test'}, child_1, child_2)", ), ( - 'html.div(dict(tagName="test"))', - None, + "html.div(dict(some_attr=1), child_1, child_2, key='test')", + "html.div(dict(some_attr=1, key='test'), child_1, child_2)", ), ( - 'html.not_an_html_tag({"className": "test"})', - None, - ), - ( - 'html.div(class_name="test")', - None, - ), - ( - # we don't try to interpret the logic here - '(div or button)({"className": "test"})', - None, + "vdom('div', dict(some_attr=1), child_1, child_2, key='test')", + "vdom('div', dict(some_attr=1, key='test'), child_1, child_2)", ), # avoid unnecessary changes ( """ def my_function(): x = 1 # some comment - return html.div({"className": "test"}) + return html.div(key='test') """, """ def my_function(): x = 1 # some comment - return html.div(class_name='test') + return html.div({'key': 'test'}) """, ), ( """ if condition: # some comment - dom = html.div({"className": "test"}) + dom = html.div(key='test') """, """ if condition: # some comment - dom = html.div(class_name='test') + dom = html.div({'key': 'test'}) """, ), ( """ [ - html.div({"className": "test"}), - html.div({"className": "test"}), + html.div(key='test'), + html.div(key='test'), ] """, """ [ - html.div(class_name='test'), - html.div(class_name='test'), + html.div({'key': 'test'}), + html.div({'key': 'test'}), ] """, ), ( """ @deco( - html.div({"className": "test"}), - html.div({"className": "test"}), + html.div(key='test'), + html.div(key='test'), ) def func(): # comment @@ -169,8 +130,8 @@ def func(): """, """ @deco( - html.div(class_name='test'), - html.div(class_name='test'), + html.div({'key': 'test'}), + html.div({'key': 'test'}), ) def func(): # comment @@ -181,7 +142,7 @@ def func(): ), ( """ - @deco(html.div({"className": "test"}), html.div({"className": "test"})) + @deco(html.div(key='test'), html.div(key='test')) def func(): # comment x = [ @@ -189,7 +150,7 @@ def func(): ] """, """ - @deco(html.div(class_name='test'), html.div(class_name='test')) + @deco(html.div({'key': 'test'}), html.div({'key': 'test'})) def func(): # comment x = [ @@ -202,14 +163,14 @@ def func(): ( result if condition - else html.div({"className": "test"}) + else html.div(key='test') ) """, """ ( result if condition - else html.div(class_name='test') + else html.div({'key': 'test'}) ) """, ), @@ -218,19 +179,51 @@ def func(): """ x = 1 html.div( + "hello", # comment 1 - {"className": "outer"}, + html.div(key='test'), # comment 2 - html.div({"className": "inner"}), + key='test', ) """, """ x = 1 # comment 1 # comment 2 - html.div(html.div(class_name='inner'), class_name='outer') + html.div({'key': 'test'}, 'hello', html.div({'key': 'test'})) """, ), + # no rewrites + ( + "html.no_an_element(key='test')", + None, + ), + ( + "html.div()", + None, + ), + ( + "html.div(not_key='something')", + None, + ), + ( + "vdom()", + None, + ), + ( + "(some + expr)(key='test')", + None, + ), + ("html.div()", None), + # too ambiguous to rewrite + ( + "html.div(child_1, child_2, key='test')", # unclear if child_1 is attr dict + None, + ), + ( + "vdom('div', child_1, child_2, key='test')", # unclear if child_1 is attr dict + None, + ), ], ids=lambda item: " ".join(map(str.strip, item.split())) if isinstance(item, str) From 05da0138e40f04889715b080e7819ca7767734a7 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 12 Feb 2023 16:20:48 -0800 Subject: [PATCH 03/19] changelog --- docs/source/about/changelog.rst | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index f9b58e0d2..a97cc95e5 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -25,7 +25,13 @@ Unreleased **Changed** -- :pull:`919` - reverts :pull:`841` as per the conclusion in :discussion:`916` +- :pull:`919` - Reverts :pull:`841` as per the conclusion in :discussion:`916`. but + preserves the ability to declare attributes with snake_case. + +**Deprecated** + +- :pull:`919` - Declaration of keys via keywork arguments in standard elements. A script + has been added to automatically convert old usages where possible. v1.0.0-a3 From 6eab33f92c498cf728678c4e1421cd2d05e140bf Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 12 Feb 2023 16:28:23 -0800 Subject: [PATCH 04/19] fix types --- src/idom/_console/rewrite_key_declarations.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/idom/_console/rewrite_key_declarations.py b/src/idom/_console/rewrite_key_declarations.py index 25f90630c..e7f77389b 100644 --- a/src/idom/_console/rewrite_key_declarations.py +++ b/src/idom/_console/rewrite_key_declarations.py @@ -9,7 +9,7 @@ from textwrap import indent from tokenize import COMMENT as COMMENT_TOKEN from tokenize import generate_tokens -from typing import Iterator +from typing import Any, Iterator import click @@ -94,7 +94,7 @@ def find_nodes_to_change(tree: ast.AST) -> list[Sequence[ast.AST]]: else: continue - maybe_attr_dict_node = None + maybe_attr_dict_node: Any | None = None if name == "vdom": if len(node.args) == 1: # vdom("tag") need to add attr dict @@ -139,7 +139,7 @@ def find_nodes_to_change(tree: ast.AST) -> list[Sequence[ast.AST]]: def rewrite_changed_nodes( - file: str, + file: Path, source: str, tree: ast.AST, changed: list[Sequence[ast.AST]], @@ -210,7 +210,7 @@ def rewrite_changed_nodes( return "\n".join(lines) -def log_could_not_rewrite(file: str, tree: ast.AST) -> None: +def log_could_not_rewrite(file: Path, tree: ast.AST) -> None: for node in ast.walk(tree): if not (isinstance(node, ast.Call) and node.keywords): continue From a6a71bc4f74d2c72ce68ae0918cd8f6386bc5906 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 12 Feb 2023 17:26:44 -0800 Subject: [PATCH 05/19] apply rewrites --- .../_examples/dict_remove.py | 2 +- .../_examples/list_insert.py | 2 +- .../_examples/list_re_order.py | 2 +- .../_examples/list_remove.py | 2 +- .../_examples/list_replace.py | 2 +- .../_examples/set_remove.py | 10 +++++----- .../_examples/set_update.py | 10 +++++----- .../_examples/todo_list_with_keys.py | 2 +- .../reference/_examples/matplotlib_plot.py | 3 +-- docs/source/reference/_examples/snake_game.py | 7 +++---- src/idom/_console/rewrite_key_declarations.py | 6 +++++- .../test_rewrite_key_declarations.py | 4 ++++ tests/test_core/test_layout.py | 19 +++++++++---------- tests/test_html.py | 4 ++-- 14 files changed, 40 insertions(+), 35 deletions(-) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_remove.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_remove.py index cf1955301..687dacb69 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_remove.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_remove.py @@ -43,12 +43,12 @@ def handle_click(event): html.hr(), [ html.div( + {"key": term}, html.button( {"onClick": make_delete_click_handler(term)}, "delete term" ), html.dt(term), html.dd(definition), - key=term, ) for term, definition in all_terms.items() ], diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_insert.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_insert.py index 374198f45..d4cb9a3c2 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_insert.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_insert.py @@ -18,7 +18,7 @@ def handle_click(event): html.h1("Inspiring sculptors:"), html.input({"value": artist_to_add, "onChange": handle_change}), html.button({"onClick": handle_click}, "add"), - html.ul([html.li(name, key=name) for name in artists]), + html.ul([html.li({"key": name}, name) for name in artists]), ) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_re_order.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_re_order.py index 32a0e47c0..6f2191e97 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_re_order.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_re_order.py @@ -17,7 +17,7 @@ def handle_reverse_click(event): html.h1("Inspiring sculptors:"), html.button({"onClick": handle_sort_click}, "sort"), html.button({"onClick": handle_reverse_click}, "reverse"), - html.ul([html.li(name, key=name) for name in artists]), + html.ul([html.li({"key": name}, name) for name in artists]), ) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_remove.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_remove.py index 170deb2f4..07cb877f5 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_remove.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_remove.py @@ -29,9 +29,9 @@ def handle_click(event): html.ul( [ html.li( + {"key": name}, name, html.button({"onClick": make_handle_delete_click(index)}, "delete"), - key=name, ) for index, name in enumerate(artists) ] diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_replace.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_replace.py index 2fafcfde8..d1504aedd 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_replace.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_replace.py @@ -15,9 +15,9 @@ def handle_click(event): return html.ul( [ html.li( + {"key": index}, count, html.button({"onClick": make_increment_click_handler(index)}, "+1"), - key=index, ) for index, count in enumerate(counters) ] diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py index 4c4905350..8184117cf 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py @@ -24,14 +24,14 @@ def handle_click(event): "style": { "height": "30px", "width": "30px", - "backgroundColor": ( - "black" if index in selected_indices else "white" - ), + "backgroundColor": "black" + if index in selected_indices + else "white", "outline": "1px solid grey", "cursor": "pointer", }, - }, - key=index, + "key": index, + } ) for index in range(line_size) ], diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py index 9e1077cb0..524b96ca9 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py @@ -21,14 +21,14 @@ def handle_click(event): "style": { "height": "30px", "width": "30px", - "backgroundColor": ( - "black" if index in selected_indices else "white" - ), + "backgroundColor": "black" + if index in selected_indices + else "white", "outline": "1px solid grey", "cursor": "pointer", }, - }, - key=index, + "key": index, + } ) for index in range(line_size) ], diff --git a/docs/source/guides/creating-interfaces/rendering-data/_examples/todo_list_with_keys.py b/docs/source/guides/creating-interfaces/rendering-data/_examples/todo_list_with_keys.py index 5a173e1e0..6d9360443 100644 --- a/docs/source/guides/creating-interfaces/rendering-data/_examples/todo_list_with_keys.py +++ b/docs/source/guides/creating-interfaces/rendering-data/_examples/todo_list_with_keys.py @@ -7,7 +7,7 @@ def DataList(items, filter_by_priority=None, sort_by_priority=False): items = [i for i in items if i["priority"] <= filter_by_priority] if sort_by_priority: items = list(sorted(items, key=lambda i: i["priority"])) - list_item_elements = [html.li(i["text"], key=i["id"]) for i in items] + list_item_elements = [html.li({"key": i["id"]}, i["text"]) for i in items] return html.ul(list_item_elements) diff --git a/docs/source/reference/_examples/matplotlib_plot.py b/docs/source/reference/_examples/matplotlib_plot.py index 6dffb79db..eb8210544 100644 --- a/docs/source/reference/_examples/matplotlib_plot.py +++ b/docs/source/reference/_examples/matplotlib_plot.py @@ -58,7 +58,7 @@ def plot(title, x, y): def poly_coef_input(index, callback): return idom.html.div( - {"style": {"margin-top": "5px"}}, + {"style": {"margin-top": "5px"}, "key": index}, idom.html.label( "C", idom.html.sub(index), @@ -71,7 +71,6 @@ def poly_coef_input(index, callback): "onChange": callback, }, ), - key=index, ) diff --git a/docs/source/reference/_examples/snake_game.py b/docs/source/reference/_examples/snake_game.py index 92fe054f0..b9f9938dd 100644 --- a/docs/source/reference/_examples/snake_game.py +++ b/docs/source/reference/_examples/snake_game.py @@ -153,12 +153,11 @@ def create_grid(grid_size, block_scale): }, [ idom.html.div( - {"style": {"height": f"{block_scale}px"}}, + {"style": {"height": f"{block_scale}px"}, "key": i}, [ create_grid_block("black", block_scale, key=i) for i in range(grid_size) ], - key=i, ) for i in range(grid_size) ], @@ -173,9 +172,9 @@ def create_grid_block(color, block_scale, key): "width": f"{block_scale}px", "backgroundColor": color, "outline": "1px solid grey", - } + }, + "key": key, }, - key=key, ) diff --git a/src/idom/_console/rewrite_key_declarations.py b/src/idom/_console/rewrite_key_declarations.py index e7f77389b..0d880d79d 100644 --- a/src/idom/_console/rewrite_key_declarations.py +++ b/src/idom/_console/rewrite_key_declarations.py @@ -81,7 +81,11 @@ def find_nodes_to_change(tree: ast.AST) -> list[Sequence[ast.AST]]: continue func = node.func - if isinstance(func, ast.Attribute): + if ( + isinstance(func, ast.Attribute) + and isinstance(func.value, ast.Name) + and func.value.id == "html" + ): name = func.attr elif isinstance(func, ast.Name): name = func.id diff --git a/tests/test__console/test_rewrite_key_declarations.py b/tests/test__console/test_rewrite_key_declarations.py index 39bd10e00..5058521ec 100644 --- a/tests/test__console/test_rewrite_key_declarations.py +++ b/tests/test__console/test_rewrite_key_declarations.py @@ -198,6 +198,10 @@ def func(): "html.no_an_element(key='test')", None, ), + ( + "not_html.div(key='test')", + None, + ), ( "html.div()", None, diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index f97a2db22..b820bea2c 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -506,9 +506,11 @@ def bad_trigger(): children = [ idom.html.button( - {"onClick": good_trigger, "id": "good"}, "good", key="good" + {"onClick": good_trigger, "id": "good", "key": "good"}, "good" + ), + idom.html.button( + {"onClick": bad_trigger, "id": "bad", "key": "bad"}, "bad" ), - idom.html.button({"onClick": bad_trigger, "id": "bad"}, "bad", key="bad"), ] if reverse_children: @@ -692,8 +694,8 @@ async def test_duplicate_sibling_keys_causes_error(caplog): def ComponentReturnsDuplicateKeys(): if should_error: return idom.html.div( - idom.html.div(key="duplicate"), - idom.html.div(key="duplicate"), + idom.html.div({"key": "duplicate"}), + idom.html.div({"key": "duplicate"}), ) else: return idom.html.div() @@ -847,8 +849,8 @@ def SomeComponent(): return idom.html.div( [ idom.html.div( + {"key": i}, idom.html.input({"onChange": lambda event: None}), - key=str(i), ) for i in items ] @@ -879,7 +881,7 @@ async def test_changing_key_of_parent_element_unmounts_children(): @idom.component @root_hook.capture def Root(): - return idom.html.div(HasState(), key=str(random.random())) + return idom.html.div({"key": str(random.random())}, HasState()) @idom.component def HasState(): @@ -1015,10 +1017,7 @@ async def record_if_state_is_reset(): set_state(1) did_call_effect.set() - return html.div( - child_key, - key=child_key, - ) + return html.div({"key": child_key}, child_key) async with idom.Layout(Parent()) as layout: await layout.render() diff --git a/tests/test_html.py b/tests/test_html.py index 794a16474..5083bdff7 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -142,8 +142,8 @@ def test_child_of_script_must_be_string(): def test_simple_fragment(): assert html._() == {"tagName": ""} assert html._(1, 2, 3) == {"tagName": "", "children": [1, 2, 3]} - assert html._(key="something") == {"tagName": "", "key": "something"} - assert html._(1, 2, 3, key="something") == { + assert html._({"key": "something"}) == {"tagName": "", "key": "something"} + assert html._({"key": "something"}, 1, 2, 3) == { "tagName": "", "key": "something", "children": [1, 2, 3], From e421c3a91b00e138b6a687d0b77bffff3fac8930 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 12 Feb 2023 18:58:04 -0800 Subject: [PATCH 06/19] add custom vdom constructor decorator --- src/idom/core/vdom.py | 30 +++++++++++++++++++++++++++++- src/idom/html.py | 33 +++++++++++++++++++++------------ src/idom/utils.py | 23 +++++------------------ tests/test_html.py | 5 +++++ 4 files changed, 60 insertions(+), 31 deletions(-) diff --git a/src/idom/core/vdom.py b/src/idom/core/vdom.py index 998c4ff52..b298e430f 100644 --- a/src/idom/core/vdom.py +++ b/src/idom/core/vdom.py @@ -1,9 +1,11 @@ from __future__ import annotations import logging -from typing import Any, Mapping, Sequence, cast, overload +from functools import wraps +from typing import Any, Callable, Mapping, Sequence, cast, overload from fastjsonschema import compile as compile_json_schema +from typing_extensions import Protocol from idom._warnings import warn from idom.config import IDOM_DEBUG_MODE @@ -13,7 +15,9 @@ EventHandlerDict, EventHandlerType, ImportSourceDict, + Key, VdomAttributes, + VdomChild, VdomChildren, VdomDict, VdomDictConstructor, @@ -243,6 +247,19 @@ def constructor(*attributes_and_children: Any, **kwargs: Any) -> VdomDict: return cast(VdomDictConstructor, constructor) +def custom_vdom_constructor(func: _CustomVdomDictConstructor) -> VdomDictConstructor: + """Cast function to VdomDictConstructor""" + + @wraps(func) + def wrapper(*attributes_and_children: Any): + attributes, children = separate_attributes_and_children(attributes_and_children) + key = attributes.pop("key", None) + attributes, event_handlers = separate_attributes_and_event_handlers(attributes) + return func(attributes, children, key, event_handlers) + + return cast(VdomDictConstructor, wrapper) + + def separate_attributes_and_children( values: Sequence[Any], ) -> tuple[dict[str, Any], list[Any]]: @@ -322,6 +339,17 @@ def _validate_child_key_integrity(value: Any) -> None: logger.error(f"Key not specified for child in list {child_copy}") +class _CustomVdomDictConstructor(Protocol): + def __call__( + self, + attributes: VdomAttributes, + children: Sequence[VdomChild], + key: Key | None, + event_handlers: EventHandlerDict, + ) -> VdomDict: + ... + + class _EllipsisRepr: def __repr__(self) -> str: return "..." diff --git a/src/idom/html.py b/src/idom/html.py index bd99df2cd..863dabd68 100644 --- a/src/idom/html.py +++ b/src/idom/html.py @@ -157,10 +157,10 @@ from __future__ import annotations -from typing import Any, Mapping +from typing import Sequence -from idom.core.types import Key, VdomDict -from idom.core.vdom import make_vdom_constructor, separate_attributes_and_children +from idom.core.types import EventHandlerDict, Key, VdomAttributes, VdomChild, VdomDict +from idom.core.vdom import custom_vdom_constructor, make_vdom_constructor __all__ = ( @@ -278,15 +278,20 @@ ) -def _(*children: Any, key: Key | None = None) -> VdomDict: +@custom_vdom_constructor +def _( + attributes: VdomAttributes, + children: Sequence[VdomChild], + key: Key | None, + event_handlers: EventHandlerDict, +) -> VdomDict: """An HTML fragment - this element will not appear in the DOM""" - attributes, coalesced_children = separate_attributes_and_children(children) - if attributes: - raise TypeError("Fragments cannot have attributes") + if attributes or event_handlers: + raise TypeError("Fragments cannot have attributes besides 'key'") model: VdomDict = {"tagName": ""} - if coalesced_children: - model["children"] = coalesced_children + if children: + model["children"] = children if key is not None: model["key"] = key @@ -389,9 +394,12 @@ def _(*children: Any, key: Key | None = None) -> VdomDict: noscript = make_vdom_constructor("noscript") +@custom_vdom_constructor def script( - *attributes_and_children: Mapping[str, Any] | str, - key: str | int | None = None, + attributes: VdomAttributes, + children: Sequence[VdomChild], + key: Key | None, + event_handlers: EventHandlerDict, ) -> VdomDict: """Create a new `<{script}> `__ element. @@ -408,7 +416,8 @@ def script( """ model: VdomDict = {"tagName": "script"} - attributes, children = separate_attributes_and_children(attributes_and_children) + if event_handlers: + raise TypeError("'script' elements do not support event handlers") if children: if len(children) > 1: diff --git a/src/idom/utils.py b/src/idom/utils.py index 20e45cac5..e20152e15 100644 --- a/src/idom/utils.py +++ b/src/idom/utils.py @@ -7,8 +7,8 @@ from lxml import etree from lxml.html import fromstring, tostring -import idom from idom.core.types import VdomDict +from idom.core.vdom import vdom _RefValue = TypeVar("_RefValue") @@ -149,29 +149,16 @@ def _etree_to_vdom( children = _generate_vdom_children(node, transforms) # Convert the lxml node to a VDOM dict - attributes = dict(node.items()) - key = attributes.pop("key", None) - - vdom: VdomDict - if hasattr(idom.html, node.tag): - vdom = getattr(idom.html, node.tag)(attributes, *children, key=key) - else: - vdom = {"tagName": node.tag} - if children: - vdom["children"] = children - if attributes: - vdom["attributes"] = attributes - if key is not None: - vdom["key"] = key + el = vdom(node.tag, dict(node.items()), *children) # Perform any necessary mutations on the VDOM attributes to meet VDOM spec - _mutate_vdom(vdom) + _mutate_vdom(el) # Apply any provided transforms. for transform in transforms: - vdom = transform(vdom) + el = transform(el) - return vdom + return el def _add_vdom_to_etree(parent: etree._Element, vdom: VdomDict | dict[str, Any]) -> None: diff --git a/tests/test_html.py b/tests/test_html.py index 5083bdff7..236a37f16 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -139,6 +139,11 @@ def test_child_of_script_must_be_string(): html.script(1) +def test_script_has_no_event_handlers(): + with pytest.raises(ValueError, match="do not support event handlers"): + html.script({"on_event": lambda: None}) + + def test_simple_fragment(): assert html._() == {"tagName": ""} assert html._(1, 2, 3) == {"tagName": "", "children": [1, 2, 3]} From ec1eb3864e319957a6668a48e4ea0a43c599b183 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 12 Feb 2023 19:07:20 -0800 Subject: [PATCH 07/19] change to snake --- tests/test_core/test_component.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_core/test_component.py b/tests/test_core/test_component.py index 28c8b00f2..0443ba013 100644 --- a/tests/test_core/test_component.py +++ b/tests/test_core/test_component.py @@ -37,9 +37,9 @@ async def test_component_with_var_args(): def ComponentWithVarArgsAndKwargs(*args, **kwargs): return idom.html.div(kwargs, args) - assert ComponentWithVarArgsAndKwargs("hello", "world", myAttr=1).render() == { + assert ComponentWithVarArgsAndKwargs("hello", "world", my_attr=1).render() == { "tagName": "div", - "attributes": {"myAttr": 1}, + "attributes": {"my_attr": 1}, "children": ["hello", "world"], } From 0e87c0a09f234ba0683b5354e1d075bfce6a89f9 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 12 Feb 2023 19:10:19 -0800 Subject: [PATCH 08/19] fix err msg --- src/idom/html.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/idom/html.py b/src/idom/html.py index 863dabd68..c3a305190 100644 --- a/src/idom/html.py +++ b/src/idom/html.py @@ -417,7 +417,7 @@ def script( model: VdomDict = {"tagName": "script"} if event_handlers: - raise TypeError("'script' elements do not support event handlers") + raise ValueError("'script' elements do not support event handlers") if children: if len(children) > 1: From 2f95d586bf304431efa0e38b88c1bd68a346a9e3 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 12 Feb 2023 19:15:06 -0800 Subject: [PATCH 09/19] cast keys to strings --- src/idom/core/vdom.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/idom/core/vdom.py b/src/idom/core/vdom.py index b298e430f..ea592789c 100644 --- a/src/idom/core/vdom.py +++ b/src/idom/core/vdom.py @@ -205,8 +205,8 @@ def vdom( if children: model["children"] = children - if key: - model["key"] = key + if key is not None: + model["key"] = str(key) if event_handlers: model["eventHandlers"] = event_handlers From 78623a082920fdce32bcc87a7a896da97d267ff7 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 12 Feb 2023 19:17:01 -0800 Subject: [PATCH 10/19] make pub --- src/idom/core/vdom.py | 2 +- src/idom/utils.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/idom/core/vdom.py b/src/idom/core/vdom.py index ea592789c..57dcfe49e 100644 --- a/src/idom/core/vdom.py +++ b/src/idom/core/vdom.py @@ -206,7 +206,7 @@ def vdom( model["children"] = children if key is not None: - model["key"] = str(key) + model["key"] = key if event_handlers: model["eventHandlers"] = event_handlers diff --git a/src/idom/utils.py b/src/idom/utils.py index e20152e15..4e8e052b8 100644 --- a/src/idom/utils.py +++ b/src/idom/utils.py @@ -282,7 +282,7 @@ def _vdom_attr_to_html_str(key: str, value: Any) -> tuple[str, str]: # camel to aria-* attributes or key.startswith("aria") # handle special cases - or key in _DASHED_HTML_ATTRS + or key in DASHED_HTML_ATTRS ): key = _CAMEL_CASE_SUB_PATTERN.sub("-", key) @@ -295,9 +295,9 @@ def _vdom_attr_to_html_str(key: str, value: Any) -> tuple[str, str]: return key.lower(), str(value) -# Pattern for delimitting camelCase names (e.g. camelCase to camel-case) -_CAMEL_CASE_SUB_PATTERN = re.compile(r"(? Date: Sun, 12 Feb 2023 19:17:58 -0800 Subject: [PATCH 11/19] fix dashed html attrs --- src/idom/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/idom/utils.py b/src/idom/utils.py index 4e8e052b8..1b57bcf7c 100644 --- a/src/idom/utils.py +++ b/src/idom/utils.py @@ -297,7 +297,7 @@ def _vdom_attr_to_html_str(key: str, value: Any) -> tuple[str, str]: # see list of HTML attributes with dashes in them: # https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#attribute_list -DASHED_HTML_ATTRS = {"acceptCharset", "httpEquiv"} +DASHED_HTML_ATTRS = {"accept_charset", "http_equiv"} # Pattern for delimitting camelCase names (e.g. camelCase to camel-case) _CAMEL_CASE_SUB_PATTERN = re.compile(r"(? Date: Sun, 12 Feb 2023 19:20:02 -0800 Subject: [PATCH 12/19] rename to rewrite-keys --- src/idom/__main__.py | 4 ++-- .../{rewrite_key_declarations.py => rewrite_keys.py} | 2 +- ..._rewrite_key_declarations.py => test_rewrite_keys.py} | 9 +++------ 3 files changed, 6 insertions(+), 9 deletions(-) rename src/idom/_console/{rewrite_key_declarations.py => rewrite_keys.py} (99%) rename tests/test__console/{test_rewrite_key_declarations.py => test_rewrite_keys.py} (97%) diff --git a/src/idom/__main__.py b/src/idom/__main__.py index 30d9cc55e..feb73a0f3 100644 --- a/src/idom/__main__.py +++ b/src/idom/__main__.py @@ -1,7 +1,7 @@ import click import idom -from idom._console.rewrite_key_declarations import rewrite_key_declarations +from idom._console.rewrite_keys import rewrite_keys @click.group() @@ -10,7 +10,7 @@ def app() -> None: pass -app.add_command(rewrite_key_declarations) +app.add_command(rewrite_keys) if __name__ == "__main__": diff --git a/src/idom/_console/rewrite_key_declarations.py b/src/idom/_console/rewrite_keys.py similarity index 99% rename from src/idom/_console/rewrite_key_declarations.py rename to src/idom/_console/rewrite_keys.py index 0d880d79d..0621fe6db 100644 --- a/src/idom/_console/rewrite_key_declarations.py +++ b/src/idom/_console/rewrite_keys.py @@ -21,7 +21,7 @@ @click.command() @click.argument("paths", nargs=-1, type=click.Path(exists=True)) -def rewrite_key_declarations(paths: list[str]) -> None: +def rewrite_keys(paths: list[str]) -> None: """Rewrite files under the given paths using the new html element API. The old API required users to pass a dictionary of attributes to html element diff --git a/tests/test__console/test_rewrite_key_declarations.py b/tests/test__console/test_rewrite_keys.py similarity index 97% rename from tests/test__console/test_rewrite_key_declarations.py rename to tests/test__console/test_rewrite_keys.py index 5058521ec..ac0edf4e0 100644 --- a/tests/test__console/test_rewrite_key_declarations.py +++ b/tests/test__console/test_rewrite_keys.py @@ -5,10 +5,7 @@ import pytest from click.testing import CliRunner -from idom._console.rewrite_key_declarations import ( - generate_rewrite, - rewrite_key_declarations, -) +from idom._console.rewrite_keys import generate_rewrite, rewrite_keys if sys.version_info < (3, 9): @@ -21,7 +18,7 @@ def test_rewrite_key_declarations(tmp_path): tempfile: Path = tmp_path / "temp.py" tempfile.write_text("html.div(key='test')") result = runner.invoke( - rewrite_key_declarations, + rewrite_keys, args=[str(tmp_path)], catch_exceptions=False, ) @@ -34,7 +31,7 @@ def test_rewrite_key_declarations_no_files(): runner = CliRunner() result = runner.invoke( - rewrite_key_declarations, + rewrite_keys, args=["directory-does-no-exist"], catch_exceptions=False, ) From d53154d26ac52ba3407b5c20ef8168af0da02301 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 12 Feb 2023 19:21:06 -0800 Subject: [PATCH 13/19] fix types --- src/idom/core/vdom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/idom/core/vdom.py b/src/idom/core/vdom.py index 57dcfe49e..ca6978b1b 100644 --- a/src/idom/core/vdom.py +++ b/src/idom/core/vdom.py @@ -251,7 +251,7 @@ def custom_vdom_constructor(func: _CustomVdomDictConstructor) -> VdomDictConstru """Cast function to VdomDictConstructor""" @wraps(func) - def wrapper(*attributes_and_children: Any): + def wrapper(*attributes_and_children: Any) -> VdomDict: attributes, children = separate_attributes_and_children(attributes_and_children) key = attributes.pop("key", None) attributes, event_handlers = separate_attributes_and_event_handlers(attributes) From 8d8e0b600ccde8a866cd06e0ba5169ae611b349d Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 12 Feb 2023 19:24:35 -0800 Subject: [PATCH 14/19] allow ints in vdom spec for keys --- src/idom/core/vdom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/idom/core/vdom.py b/src/idom/core/vdom.py index ca6978b1b..6182cce16 100644 --- a/src/idom/core/vdom.py +++ b/src/idom/core/vdom.py @@ -38,7 +38,7 @@ "type": "object", "properties": { "tagName": {"type": "string"}, - "key": {"type": "string"}, + "key": {"type": ["string", "number", "null"]}, "error": {"type": "string"}, "children": {"$ref": "#/definitions/elementChildren"}, "attributes": {"type": "object"}, From 7ee7b98b8aa84743d52b720200593610821ca840 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 12 Feb 2023 19:58:30 -0800 Subject: [PATCH 15/19] fix types --- src/idom/core/vdom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/idom/core/vdom.py b/src/idom/core/vdom.py index 6182cce16..edbe92122 100644 --- a/src/idom/core/vdom.py +++ b/src/idom/core/vdom.py @@ -2,7 +2,7 @@ import logging from functools import wraps -from typing import Any, Callable, Mapping, Sequence, cast, overload +from typing import Any, Mapping, Sequence, cast, overload from fastjsonschema import compile as compile_json_schema from typing_extensions import Protocol From f1bf70c5f0649283b5a2b37b14b7da3edd0cfb77 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Mon, 20 Feb 2023 23:56:47 -0800 Subject: [PATCH 16/19] camelCase to snake_case + rewrite util --- .../_examples/adding_state_variable/main.py | 2 +- .../_examples/isolated_state/main.py | 6 +- .../multiple_state_variables/main.py | 6 +- .../when_variables_are_not_enough/main.py | 2 +- .../_examples/dict_remove.py | 8 +- .../_examples/dict_update.py | 8 +- .../_examples/list_insert.py | 4 +- .../_examples/list_re_order.py | 4 +- .../_examples/list_remove.py | 8 +- .../_examples/list_replace.py | 2 +- .../_examples/moving_dot.py | 4 +- .../_examples/moving_dot_broken.py | 4 +- .../_examples/set_remove.py | 2 +- .../_examples/set_update.py | 2 +- .../_examples/delay_before_count_updater.py | 2 +- .../_examples/delay_before_set_count.py | 2 +- .../_examples/set_color_3_times.py | 4 +- .../_examples/set_state_function.py | 2 +- .../_examples/button_async_handlers.py | 2 +- .../_examples/button_handler_as_arg.py | 2 +- .../_examples/button_prints_event.py | 2 +- .../_examples/button_prints_message.py | 2 +- .../prevent_default_event_actions.py | 2 +- .../_examples/stop_event_propagation.py | 8 +- .../_examples/delayed_print_after_set.py | 2 +- .../_examples/print_chat_message.py | 7 +- .../_examples/print_count_after_set.py | 2 +- .../_examples/send_message.py | 6 +- .../_examples/set_counter_3_times.py | 2 +- .../_examples/filterable_list/main.py | 3 +- .../_examples/synced_inputs/main.py | 2 +- .../_examples/character_movement/main.py | 16 +- src/idom/__main__.py | 2 + src/idom/_console/ast_utils.py | 171 +++++++++++++++++ src/idom/_console/rewrite_camel_case_props.py | 89 +++++++++ src/idom/_console/rewrite_keys.py | 175 ++---------------- src/idom/utils.py | 11 +- .../test_rewrite_camel_case_props.py | 83 +++++++++ tests/test_core/test_layout.py | 4 +- tests/test_html.py | 10 +- tests/test_testing.py | 2 +- tests/test_utils.py | 8 +- 42 files changed, 452 insertions(+), 233 deletions(-) create mode 100644 src/idom/_console/ast_utils.py create mode 100644 src/idom/_console/rewrite_camel_case_props.py create mode 100644 tests/test__console/test_rewrite_camel_case_props.py diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/main.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/main.py index 724831f89..bbadfb06e 100644 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/main.py +++ b/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/main.py @@ -25,7 +25,7 @@ def handle_click(event): url = sculpture["url"] return html.div( - html.button({"onClick": handle_click}, "Next"), + html.button({"on_click": handle_click}, "Next"), html.h2(name, " by ", artist), html.p(f"({bounded_index + 1} of {len(sculpture_data)})"), html.img({"src": url, "alt": alt, "style": {"height": "200px"}}), diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/main.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/main.py index 08a53d1c6..2434f5239 100644 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/main.py +++ b/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/main.py @@ -29,14 +29,14 @@ def handle_more_click(event): url = sculpture["url"] return html.div( - html.button({"onClick": handle_next_click}, "Next"), + html.button({"on_click": handle_next_click}, "Next"), html.h2(name, " by ", artist), html.p(f"({bounded_index + 1} or {len(sculpture_data)})"), html.img({"src": url, "alt": alt, "style": {"height": "200px"}}), html.div( html.button( - {"onClick": handle_more_click}, - f"{'Show' if show_more else 'Hide'} details", + {"on_click": handle_more_click}, + f"{('Show' if show_more else 'Hide')} details", ), (html.p(description) if show_more else ""), ), diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/main.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/main.py index 3e7f7bde4..2ab39e5e0 100644 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/main.py +++ b/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/main.py @@ -29,14 +29,14 @@ def handle_more_click(event): url = sculpture["url"] return html.div( - html.button({"onClick": handle_next_click}, "Next"), + html.button({"on_click": handle_next_click}, "Next"), html.h2(name, " by ", artist), html.p(f"({bounded_index + 1} or {len(sculpture_data)})"), html.img({"src": url, "alt": alt, "style": {"height": "200px"}}), html.div( html.button( - {"onClick": handle_more_click}, - f"{'Show' if show_more else 'Hide'} details", + {"on_click": handle_more_click}, + f"{('Show' if show_more else 'Hide')} details", ), (html.p(description) if show_more else ""), ), diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/main.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/main.py index f8679cbfc..7558fc328 100644 --- a/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/main.py +++ b/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/main.py @@ -31,7 +31,7 @@ def handle_click(event): url = sculpture["url"] return html.div( - html.button({"onClick": handle_click}, "Next"), + html.button({"on_click": handle_click}, "Next"), html.h2(name, " by ", artist), html.p(f"({bounded_index + 1} or {len(sculpture_data)})"), html.img({"src": url, "alt": alt, "style": {"height": "200px"}}), diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_remove.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_remove.py index 687dacb69..c3ca24266 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_remove.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_remove.py @@ -26,17 +26,17 @@ def handle_click(event): return handle_click return html.div( - html.button({"onClick": handle_add_click}, "add term"), + html.button({"on_click": handle_add_click}, "add term"), html.label( "Term: ", - html.input({"value": term_to_add, "onChange": handle_term_to_add_change}), + html.input({"value": term_to_add, "on_change": handle_term_to_add_change}), ), html.label( "Definition: ", html.input( { "value": definition_to_add, - "onChange": handle_definition_to_add_change, + "on_change": handle_definition_to_add_change, } ), ), @@ -45,7 +45,7 @@ def handle_click(event): html.div( {"key": term}, html.button( - {"onClick": make_delete_click_handler(term)}, "delete term" + {"on_click": make_delete_click_handler(term)}, "delete term" ), html.dt(term), html.dd(definition), diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_update.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_update.py index 92085c0b6..518bd3132 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_update.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/dict_update.py @@ -24,20 +24,18 @@ def handle_email_change(event): html.label( "First name: ", html.input( - {"value": person["first_name"], "onChange": handle_first_name_change}, + {"value": person["first_name"], "on_change": handle_first_name_change} ), ), html.label( "Last name: ", html.input( - {"value": person["last_name"], "onChange": handle_last_name_change}, + {"value": person["last_name"], "on_change": handle_last_name_change} ), ), html.label( "Email: ", - html.input( - {"value": person["email"], "onChange": handle_email_change}, - ), + html.input({"value": person["email"], "on_change": handle_email_change}), ), html.p(f"{person['first_name']} {person['last_name']} {person['email']}"), ) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_insert.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_insert.py index d4cb9a3c2..98617e341 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_insert.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_insert.py @@ -16,8 +16,8 @@ def handle_click(event): return html.div( html.h1("Inspiring sculptors:"), - html.input({"value": artist_to_add, "onChange": handle_change}), - html.button({"onClick": handle_click}, "add"), + html.input({"value": artist_to_add, "on_change": handle_change}), + html.button({"on_click": handle_click}, "add"), html.ul([html.li({"key": name}, name) for name in artists]), ) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_re_order.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_re_order.py index 6f2191e97..37ce97f7d 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_re_order.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_re_order.py @@ -15,8 +15,8 @@ def handle_reverse_click(event): return html.div( html.h1("Inspiring sculptors:"), - html.button({"onClick": handle_sort_click}, "sort"), - html.button({"onClick": handle_reverse_click}, "reverse"), + html.button({"on_click": handle_sort_click}, "sort"), + html.button({"on_click": handle_reverse_click}, "reverse"), html.ul([html.li({"key": name}, name) for name in artists]), ) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_remove.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_remove.py index 07cb877f5..3a9126802 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_remove.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_remove.py @@ -24,14 +24,16 @@ def handle_click(event): return html.div( html.h1("Inspiring sculptors:"), - html.input({"value": artist_to_add, "onChange": handle_change}), - html.button({"onClick": handle_add_click}, "add"), + html.input({"value": artist_to_add, "on_change": handle_change}), + html.button({"on_click": handle_add_click}, "add"), html.ul( [ html.li( {"key": name}, name, - html.button({"onClick": make_handle_delete_click(index)}, "delete"), + html.button( + {"on_click": make_handle_delete_click(index)}, "delete" + ), ) for index, name in enumerate(artists) ] diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_replace.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_replace.py index d1504aedd..ce9ae1aec 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_replace.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/list_replace.py @@ -17,7 +17,7 @@ def handle_click(event): html.li( {"key": index}, count, - html.button({"onClick": make_increment_click_handler(index)}, "+1"), + html.button({"on_click": make_increment_click_handler(index)}, "+1"), ) for index, count in enumerate(counters) ] diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot.py index d3edb8590..d2a62db14 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot.py @@ -17,7 +17,7 @@ async def handle_pointer_move(event): return html.div( { - "onPointerMove": handle_pointer_move, + "on_pointer_move": handle_pointer_move, "style": { "position": "relative", "height": "200px", @@ -36,7 +36,7 @@ async def handle_pointer_move(event): "left": "-10px", "top": "-10px", "transform": f"translate({position['x']}px, {position['y']}px)", - }, + } } ), ) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot_broken.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot_broken.py index 90885e7fe..446670d3c 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot_broken.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/moving_dot_broken.py @@ -15,7 +15,7 @@ def handle_pointer_move(event): return html.div( { - "onPointerMove": handle_pointer_move, + "on_pointer_move": handle_pointer_move, "style": { "position": "relative", "height": "200px", @@ -34,7 +34,7 @@ def handle_pointer_move(event): "left": "-10px", "top": "-10px", "transform": f"translate({position['x']}px, {position['y']}px)", - }, + } } ), ) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py index 8184117cf..a949f6e02 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py @@ -20,7 +20,7 @@ def handle_click(event): [ html.div( { - "onClick": make_handle_click(index), + "on_click": make_handle_click(index), "style": { "height": "30px", "width": "30px", diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py index 524b96ca9..84ef48d94 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py @@ -17,7 +17,7 @@ def handle_click(event): [ html.div( { - "onClick": make_handle_click(index), + "on_click": make_handle_click(index), "style": { "height": "30px", "width": "30px", diff --git a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/delay_before_count_updater.py b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/delay_before_count_updater.py index 0c000477e..77a723b03 100644 --- a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/delay_before_count_updater.py +++ b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/delay_before_count_updater.py @@ -13,7 +13,7 @@ async def handle_click(event): return html.div( html.h1(number), - html.button({"onClick": handle_click}, "Increment"), + html.button({"on_click": handle_click}, "Increment"), ) diff --git a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/delay_before_set_count.py b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/delay_before_set_count.py index 024df12e7..f3df72475 100644 --- a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/delay_before_set_count.py +++ b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/delay_before_set_count.py @@ -13,7 +13,7 @@ async def handle_click(event): return html.div( html.h1(number), - html.button({"onClick": handle_click}, "Increment"), + html.button({"on_click": handle_click}, "Increment"), ) diff --git a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_color_3_times.py b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_color_3_times.py index e755c35b9..4453bf9f0 100644 --- a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_color_3_times.py +++ b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_color_3_times.py @@ -15,10 +15,10 @@ def handle_reset(event): return html.div( html.button( - {"onClick": handle_click, "style": {"backgroundColor": color}}, "Set Color" + {"on_click": handle_click, "style": {"backgroundColor": color}}, "Set Color" ), html.button( - {"onClick": handle_reset, "style": {"backgroundColor": color}}, "Reset" + {"on_click": handle_reset, "style": {"backgroundColor": color}}, "Reset" ), ) diff --git a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_state_function.py b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_state_function.py index ec3193de9..cdf8931fe 100644 --- a/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_state_function.py +++ b/docs/source/guides/adding-interactivity/multiple-state-updates/_examples/set_state_function.py @@ -17,7 +17,7 @@ def handle_click(event): return html.div( html.h1(number), - html.button({"onClick": handle_click}, "Increment"), + html.button({"on_click": handle_click}, "Increment"), ) diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_async_handlers.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_async_handlers.py index a355f6142..a1c57eb03 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_async_handlers.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_async_handlers.py @@ -9,7 +9,7 @@ async def handle_event(event): await asyncio.sleep(delay) print(message) - return html.button({"onClick": handle_event}, message) + return html.button({"on_click": handle_event}, message) @component diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_handler_as_arg.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_handler_as_arg.py index 4de22a024..f08bcd95f 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_handler_as_arg.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_handler_as_arg.py @@ -3,7 +3,7 @@ @component def Button(display_text, on_click): - return html.button({"onClick": on_click}, display_text) + return html.button({"on_click": on_click}, display_text) @component diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_event.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_event.py index eac05a588..32854d8f6 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_event.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_event.py @@ -6,7 +6,7 @@ def Button(): def handle_event(event): print(event) - return html.button({"onClick": handle_event}, "Click me!") + return html.button({"on_click": handle_event}, "Click me!") run(Button) diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_message.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_message.py index f5ee69f80..e2790ce52 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_message.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/button_prints_message.py @@ -6,7 +6,7 @@ def PrintButton(display_text, message_text): def handle_event(event): print(message_text) - return html.button({"onClick": handle_event}, display_text) + return html.button({"on_click": handle_event}, display_text) @component diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/prevent_default_event_actions.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/prevent_default_event_actions.py index 7e8ef9938..3b9cfc1ae 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/prevent_default_event_actions.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/prevent_default_event_actions.py @@ -7,7 +7,7 @@ def DoNotChangePages(): html.p("Normally clicking this link would take you to a new page"), html.a( { - "onClick": event(lambda event: None, prevent_default=True), + "on_click": event(lambda event: None, prevent_default=True), "href": "https://google.com", }, "https://google.com", diff --git a/docs/source/guides/adding-interactivity/responding-to-events/_examples/stop_event_propagation.py b/docs/source/guides/adding-interactivity/responding-to-events/_examples/stop_event_propagation.py index e87bae026..944c48be0 100644 --- a/docs/source/guides/adding-interactivity/responding-to-events/_examples/stop_event_propagation.py +++ b/docs/source/guides/adding-interactivity/responding-to-events/_examples/stop_event_propagation.py @@ -9,23 +9,23 @@ def DivInDiv(): div_in_div = html.div( { - "onClick": lambda event: set_outer_count(outer_count + 1), + "on_click": lambda event: set_outer_count(outer_count + 1), "style": {"height": "100px", "width": "100px", "backgroundColor": "red"}, }, html.div( { - "onClick": event( + "on_click": event( lambda event: set_inner_count(inner_count + 1), stop_propagation=stop_propagatation, ), "style": {"height": "50px", "width": "50px", "backgroundColor": "blue"}, - }, + } ), ) return html.div( html.button( - {"onClick": lambda event: set_stop_propagatation(not stop_propagatation)}, + {"on_click": lambda event: set_stop_propagatation(not stop_propagatation)}, "Toggle Propogation", ), html.pre(f"Will propagate: {not stop_propagatation}"), diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/delayed_print_after_set.py b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/delayed_print_after_set.py index 5471616d4..9a554010d 100644 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/delayed_print_after_set.py +++ b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/delayed_print_after_set.py @@ -15,7 +15,7 @@ async def handle_click(event): return html.div( html.h1(number), - html.button({"onClick": handle_click}, "Increment"), + html.button({"on_click": handle_click}, "Increment"), ) diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_chat_message.py b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_chat_message.py index 35fbc23fb..bc9c05d2f 100644 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_chat_message.py +++ b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_chat_message.py @@ -16,13 +16,14 @@ async def handle_submit(event): print(f"Sent '{message}' to {recipient}") return html.form( - {"onSubmit": handle_submit, "style": {"display": "inline-grid"}}, + {"on_submit": handle_submit, "style": {"display": "inline-grid"}}, html.label( + {}, "To: ", html.select( { "value": recipient, - "onChange": lambda event: set_recipient(event["target"]["value"]), + "on_change": lambda event: set_recipient(event["target"]["value"]), }, html.option({"value": "Alice"}, "Alice"), html.option({"value": "Bob"}, "Bob"), @@ -33,7 +34,7 @@ async def handle_submit(event): "type": "text", "placeholder": "Your message...", "value": message, - "onChange": lambda event: set_message(event["target"]["value"]), + "on_change": lambda event: set_message(event["target"]["value"]), } ), html.button({"type": "submit"}, "Send"), diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_count_after_set.py b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_count_after_set.py index 039a261d9..38ca0c005 100644 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_count_after_set.py +++ b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/print_count_after_set.py @@ -11,7 +11,7 @@ def handle_click(event): return html.div( html.h1(number), - html.button({"onClick": handle_click}, "Increment"), + html.button({"on_click": handle_click}, "Increment"), ) diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/send_message.py b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/send_message.py index 0ceaf8850..c92c2c206 100644 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/send_message.py +++ b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/send_message.py @@ -10,7 +10,7 @@ def App(): return html.div( html.h1("Message sent!"), html.button( - {"onClick": lambda event: set_is_sent(False)}, "Send new message?" + {"on_click": lambda event: set_is_sent(False)}, "Send new message?" ), ) @@ -20,12 +20,12 @@ def handle_submit(event): set_is_sent(True) return html.form( - {"onSubmit": handle_submit, "style": {"display": "inline-grid"}}, + {"on_submit": handle_submit, "style": {"display": "inline-grid"}}, html.textarea( { "placeholder": "Your message here...", "value": message, - "onChange": lambda event: set_message(event["target"]["value"]), + "on_change": lambda event: set_message(event["target"]["value"]), } ), html.button({"type": "submit"}, "Send"), diff --git a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/set_counter_3_times.py b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/set_counter_3_times.py index 24801d47b..a09c6d34b 100644 --- a/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/set_counter_3_times.py +++ b/docs/source/guides/adding-interactivity/state-as-a-snapshot/_examples/set_counter_3_times.py @@ -12,7 +12,7 @@ def handle_click(event): return html.div( html.h1(number), - html.button({"onClick": handle_click}, "Increment"), + html.button({"on_click": handle_click}, "Increment"), ) diff --git a/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py b/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py index 9b0658371..2132f8482 100644 --- a/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py +++ b/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py @@ -21,7 +21,8 @@ def handle_change(event): set_value(event["target"]["value"]) return html.label( - "Search by Food Name: ", html.input({"value": value, "onChange": handle_change}) + "Search by Food Name: ", + html.input({"value": value, "on_change": handle_change}), ) diff --git a/docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py b/docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py index dcc3e1246..cf7aa3d82 100644 --- a/docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py +++ b/docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py @@ -16,7 +16,7 @@ def handle_change(event): set_value(event["target"]["value"]) return html.label( - label + " ", html.input({"value": value, "onChange": handle_change}) + label + " ", html.input({"value": value, "on_change": handle_change}) ) diff --git a/docs/source/reference/_examples/character_movement/main.py b/docs/source/reference/_examples/character_movement/main.py index fbf257a32..1c5e0a9d3 100644 --- a/docs/source/reference/_examples/character_movement/main.py +++ b/docs/source/reference/_examples/character_movement/main.py @@ -58,12 +58,16 @@ def Scene(): }, ), ), - html.button({"onClick": lambda e: set_position(translate(x=-10))}, "Move Left"), - html.button({"onClick": lambda e: set_position(translate(x=10))}, "Move Right"), - html.button({"onClick": lambda e: set_position(translate(y=-10))}, "Move Up"), - html.button({"onClick": lambda e: set_position(translate(y=10))}, "Move Down"), - html.button({"onClick": lambda e: set_position(rotate(-30))}, "Rotate Left"), - html.button({"onClick": lambda e: set_position(rotate(30))}, "Rotate Right"), + html.button( + {"on_click": lambda e: set_position(translate(x=-10))}, "Move Left" + ), + html.button( + {"on_click": lambda e: set_position(translate(x=10))}, "Move Right" + ), + html.button({"on_click": lambda e: set_position(translate(y=-10))}, "Move Up"), + html.button({"on_click": lambda e: set_position(translate(y=10))}, "Move Down"), + html.button({"on_click": lambda e: set_position(rotate(-30))}, "Rotate Left"), + html.button({"on_click": lambda e: set_position(rotate(30))}, "Rotate Right"), ) diff --git a/src/idom/__main__.py b/src/idom/__main__.py index feb73a0f3..632269ae4 100644 --- a/src/idom/__main__.py +++ b/src/idom/__main__.py @@ -1,6 +1,7 @@ import click import idom +from idom._console.rewrite_camel_case_props import rewrite_camel_case_props from idom._console.rewrite_keys import rewrite_keys @@ -11,6 +12,7 @@ def app() -> None: app.add_command(rewrite_keys) +app.add_command(rewrite_camel_case_props) if __name__ == "__main__": diff --git a/src/idom/_console/ast_utils.py b/src/idom/_console/ast_utils.py new file mode 100644 index 000000000..ace429010 --- /dev/null +++ b/src/idom/_console/ast_utils.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import ast +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path +from textwrap import indent +from tokenize import COMMENT as COMMENT_TOKEN +from tokenize import generate_tokens +from typing import Any, Iterator + +import click + +from idom import html + + +def rewrite_changed_nodes( + file: Path, + source: str, + tree: ast.AST, + changed: list[ChangedNode], +) -> str: + ast.fix_missing_locations(tree) + + lines = source.split("\n") + + # find closest parent nodes that should be re-written + nodes_to_unparse: list[ast.AST] = [] + for change in changed: + node_lineage = [change.node, *change.parents] + for i in range(len(node_lineage) - 1): + current_node, next_node = node_lineage[i : i + 2] + if ( + not hasattr(next_node, "lineno") + or next_node.lineno < change.node.lineno + or isinstance(next_node, (ast.ClassDef, ast.FunctionDef)) + ): + nodes_to_unparse.append(current_node) + break + else: # pragma: no cover + raise RuntimeError("Failed to change code") + + # check if an nodes to rewrite contain eachother, pick outermost nodes + current_outermost_node, *sorted_nodes_to_unparse = list( + sorted(nodes_to_unparse, key=lambda n: n.lineno) + ) + outermost_nodes_to_unparse = [current_outermost_node] + for node in sorted_nodes_to_unparse: + if ( + not current_outermost_node.end_lineno + or node.lineno > current_outermost_node.end_lineno + ): + current_outermost_node = node + outermost_nodes_to_unparse.append(node) + + moved_comment_lines_from_end: list[int] = [] + # now actually rewrite these nodes (in reverse to avoid changes earlier in file) + for node in reversed(outermost_nodes_to_unparse): + # make a best effort to preserve any comments that we're going to overwrite + comments = _find_comments(lines[node.lineno - 1 : node.end_lineno]) + + # there may be some content just before and after the content we're re-writing + before_replacement = lines[node.lineno - 1][: node.col_offset].lstrip() + + after_replacement = ( + lines[node.end_lineno - 1][node.end_col_offset :].strip() + if node.end_lineno is not None and node.end_col_offset is not None + else "" + ) + + replacement = indent( + before_replacement + + "\n".join([*comments, ast.unparse(node)]) + + after_replacement, + " " * (node.col_offset - len(before_replacement)), + ) + + lines[node.lineno - 1 : node.end_lineno or node.lineno] = [replacement] + + if comments: + moved_comment_lines_from_end.append(len(lines) - node.lineno) + + for lineno_from_end in sorted(list(set(moved_comment_lines_from_end))): + click.echo(f"Moved comments to {file}:{len(lines) - lineno_from_end}") + + return "\n".join(lines) + + +@dataclass +class ChangedNode: + node: ast.AST + parents: Sequence[ast.AST] + + +def find_element_constructor_usages(tree: ast.AST) -> Iterator[ElementConstructorInfo]: + changed: list[Sequence[ast.AST]] = [] + for parents, node in _walk_with_parent(tree): + if not (isinstance(node, ast.Call)): + continue + + func = node.func + if ( + isinstance(func, ast.Attribute) + and isinstance(func.value, ast.Name) + and func.value.id == "html" + ): + name = func.attr + elif isinstance(func, ast.Name): + name = func.id + else: + continue + + maybe_attr_dict_node: Any | None = None + if name == "vdom": + if len(node.args) == 0: + continue + elif len(node.args) == 1: + maybe_attr_dict_node = ast.Dict(keys=[], values=[]) + node.args.append(maybe_attr_dict_node) + elif isinstance(node.args[1], (ast.Constant, ast.JoinedStr)): + maybe_attr_dict_node = ast.Dict(keys=[], values=[]) + node.args.insert(1, maybe_attr_dict_node) + elif len(node.args) >= 2: + maybe_attr_dict_node = node.args[1] + elif hasattr(html, name): + if len(node.args) == 0: + maybe_attr_dict_node = ast.Dict(keys=[], values=[]) + node.args.append(maybe_attr_dict_node) + elif isinstance(node.args[0], (ast.Constant, ast.JoinedStr)): + maybe_attr_dict_node = ast.Dict(keys=[], values=[]) + node.args.insert(0, maybe_attr_dict_node) + else: + maybe_attr_dict_node = node.args[0] + + if not maybe_attr_dict_node: + continue + + if isinstance(maybe_attr_dict_node, ast.Dict) or ( + isinstance(maybe_attr_dict_node, ast.Call) + and isinstance(maybe_attr_dict_node.func, ast.Name) + and maybe_attr_dict_node.func.id == "dict" + and isinstance(maybe_attr_dict_node.func.ctx, ast.Load) + ): + yield ElementConstructorInfo(node, maybe_attr_dict_node, parents) + + return changed + + +@dataclass +class ElementConstructorInfo: + call: ast.Call + props: ast.Dict | ast.Call + parents: Sequence[ast.AST] + + +def _find_comments(lines: list[str]) -> list[str]: + iter_lines = iter(lines) + return [ + token + for token_type, token, _, _, _ in generate_tokens(lambda: next(iter_lines)) + if token_type == COMMENT_TOKEN + ] + + +def _walk_with_parent( + node: ast.AST, parents: tuple[ast.AST, ...] = () +) -> Iterator[tuple[tuple[ast.AST, ...], ast.AST]]: + parents = (node,) + parents + for child in ast.iter_child_nodes(node): + yield parents, child + yield from _walk_with_parent(child, parents) diff --git a/src/idom/_console/rewrite_camel_case_props.py b/src/idom/_console/rewrite_camel_case_props.py new file mode 100644 index 000000000..5f407b5e2 --- /dev/null +++ b/src/idom/_console/rewrite_camel_case_props.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import ast +import re +import sys +from collections.abc import Sequence +from keyword import kwlist +from pathlib import Path + +import click + +from idom import html +from idom._console.ast_utils import ( + ChangedNode, + find_element_constructor_usages, + rewrite_changed_nodes, +) + + +CAMEL_CASE_SUB_PATTERN = re.compile(r"(? None: + """Rewrite camelCase props to snake_case""" + if sys.version_info < (3, 9): # pragma: no cover + raise RuntimeError("This command requires Python>=3.9") + + for p in map(Path, paths): + for f in [p] if p.is_file() else p.rglob("*.py"): + result = generate_rewrite(file=f, source=f.read_text()) + if result is not None: + f.write_text(result) + + +def generate_rewrite(file: Path, source: str) -> str | None: + tree = ast.parse(source) + + changed = find_nodes_to_change(tree) + if not changed: + return None + + new = rewrite_changed_nodes(file, source, tree, changed) + return new + + +def find_nodes_to_change(tree: ast.AST) -> list[ChangedNode]: + changed: list[Sequence[ast.AST]] = [] + for el_info in find_element_constructor_usages(tree): + if isinstance(el_info.props, ast.Dict): + did_change = False + keys: list[ast.Constant] = [] + for k in el_info.props.keys: + if isinstance(k, ast.Constant) and isinstance(k.value, str): + new_prop_name = conv_attr_name(k.value) + if new_prop_name != k.value: + did_change = True + keys.append(ast.Constant(conv_attr_name(k.value))) + else: + keys.append(k) + else: + keys.append(k) + if not did_change: + continue + el_info.props.keys = keys + else: + did_change = False + keywords: list[ast.keyword] = [] + for kw in el_info.props.keywords: + new_prop_name = conv_attr_name(kw.arg) + if new_prop_name != kw.arg: + did_change = True + keywords.append( + ast.keyword(arg=conv_attr_name(kw.arg), value=kw.value) + ) + else: + keywords.append(kw) + if not did_change: + continue + el_info.props.keywords = keywords + + 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).replace("-", "_").lower() + return f"{new_name}_" if new_name in kwlist else new_name diff --git a/src/idom/_console/rewrite_keys.py b/src/idom/_console/rewrite_keys.py index 0621fe6db..adaeaf49c 100644 --- a/src/idom/_console/rewrite_keys.py +++ b/src/idom/_console/rewrite_keys.py @@ -1,22 +1,18 @@ from __future__ import annotations import ast -import re import sys from collections.abc import Sequence -from dataclasses import dataclass from pathlib import Path -from textwrap import indent -from tokenize import COMMENT as COMMENT_TOKEN -from tokenize import generate_tokens -from typing import Any, Iterator import click from idom import html - - -CAMEL_CASE_SUB_PATTERN = re.compile(r"(? str | None: return new -def find_nodes_to_change(tree: ast.AST) -> list[Sequence[ast.AST]]: +def find_nodes_to_change(tree: ast.AST) -> list[ChangedNode]: changed: list[Sequence[ast.AST]] = [] - for parents, node in walk_with_parent(tree): - if not (isinstance(node, ast.Call) and node.keywords): - continue - - func = node.func - if ( - isinstance(func, ast.Attribute) - and isinstance(func.value, ast.Name) - and func.value.id == "html" - ): - name = func.attr - elif isinstance(func, ast.Name): - name = func.id - else: - continue - - for kw in list(node.keywords): + for el_info in find_element_constructor_usages(tree): + for kw in list(el_info.call.keywords): if kw.arg == "key": break else: continue - maybe_attr_dict_node: Any | None = None - if name == "vdom": - if len(node.args) == 1: - # vdom("tag") need to add attr dict - maybe_attr_dict_node = ast.Dict(keys=[], values=[]) - node.args.append(maybe_attr_dict_node) - elif isinstance(node.args[1], (ast.Constant, ast.JoinedStr)): - maybe_attr_dict_node = ast.Dict(keys=[], values=[]) - node.args.insert(1, maybe_attr_dict_node) - elif len(node.args) >= 2: - maybe_attr_dict_node = node.args[1] - elif hasattr(html, name): - if len(node.args) == 0: - # vdom("tag") need to add attr dict - maybe_attr_dict_node = ast.Dict(keys=[], values=[]) - node.args.append(maybe_attr_dict_node) - elif isinstance(node.args[0], (ast.Constant, ast.JoinedStr)): - maybe_attr_dict_node = ast.Dict(keys=[], values=[]) - node.args.insert(0, maybe_attr_dict_node) - else: - maybe_attr_dict_node = node.args[0] - - if not maybe_attr_dict_node: - continue - - if isinstance(maybe_attr_dict_node, ast.Dict): - maybe_attr_dict_node.keys.append(ast.Constant("key")) - maybe_attr_dict_node.values.append(kw.value) - elif ( - isinstance(maybe_attr_dict_node, ast.Call) - and isinstance(maybe_attr_dict_node.func, ast.Name) - and maybe_attr_dict_node.func.id == "dict" - and isinstance(maybe_attr_dict_node.func.ctx, ast.Load) - ): - maybe_attr_dict_node.keywords.append(ast.keyword(arg="key", value=kw.value)) + if isinstance(el_info.props, ast.Dict): + el_info.props.keys.append(ast.Constant("key")) + el_info.props.values.append(kw.value) else: - continue + el_info.props.keywords.append(ast.keyword(arg="key", value=kw.value)) - node.keywords.remove(kw) - changed.append((node, *parents)) + el_info.call.keywords.remove(kw) + changed.append(ChangedNode(el_info.call, el_info.parents)) return changed -def rewrite_changed_nodes( - file: Path, - source: str, - tree: ast.AST, - changed: list[Sequence[ast.AST]], -) -> str: - ast.fix_missing_locations(tree) - - lines = source.split("\n") - - # find closest parent nodes that should be re-written - nodes_to_unparse: list[ast.AST] = [] - for node_lineage in changed: - origin_node = node_lineage[0] - for i in range(len(node_lineage) - 1): - current_node, next_node = node_lineage[i : i + 2] - if ( - not hasattr(next_node, "lineno") - or next_node.lineno < origin_node.lineno - or isinstance(next_node, (ast.ClassDef, ast.FunctionDef)) - ): - nodes_to_unparse.append(current_node) - break - else: # pragma: no cover - raise RuntimeError("Failed to change code") - - # check if an nodes to rewrite contain eachother, pick outermost nodes - current_outermost_node, *sorted_nodes_to_unparse = list( - sorted(nodes_to_unparse, key=lambda n: n.lineno) - ) - outermost_nodes_to_unparse = [current_outermost_node] - for node in sorted_nodes_to_unparse: - if ( - not current_outermost_node.end_lineno - or node.lineno > current_outermost_node.end_lineno - ): - current_outermost_node = node - outermost_nodes_to_unparse.append(node) - - moved_comment_lines_from_end: list[int] = [] - # now actually rewrite these nodes (in reverse to avoid changes earlier in file) - for node in reversed(outermost_nodes_to_unparse): - # make a best effort to preserve any comments that we're going to overwrite - comments = find_comments(lines[node.lineno - 1 : node.end_lineno]) - - # there may be some content just before and after the content we're re-writing - before_replacement = lines[node.lineno - 1][: node.col_offset].lstrip() - - after_replacement = ( - lines[node.end_lineno - 1][node.end_col_offset :].strip() - if node.end_lineno is not None and node.end_col_offset is not None - else "" - ) - - replacement = indent( - before_replacement - + "\n".join([*comments, ast.unparse(node)]) - + after_replacement, - " " * (node.col_offset - len(before_replacement)), - ) - - lines[node.lineno - 1 : node.end_lineno or node.lineno] = [replacement] - - if comments: - moved_comment_lines_from_end.append(len(lines) - node.lineno) - - for lineno_from_end in sorted(list(set(moved_comment_lines_from_end))): - click.echo(f"Moved comments to {file}:{len(lines) - lineno_from_end}") - - return "\n".join(lines) - - def log_could_not_rewrite(file: Path, tree: ast.AST) -> None: for node in ast.walk(tree): if not (isinstance(node, ast.Call) and node.keywords): @@ -233,27 +110,3 @@ def log_could_not_rewrite(file: Path, tree: ast.AST) -> None: and any(kw.arg == "key" for kw in node.keywords) ): click.echo(f"Unable to rewrite usage at {file}:{node.lineno}") - - -def find_comments(lines: list[str]) -> list[str]: - iter_lines = iter(lines) - return [ - token - for token_type, token, _, _, _ in generate_tokens(lambda: next(iter_lines)) - if token_type == COMMENT_TOKEN - ] - - -def walk_with_parent( - node: ast.AST, parents: tuple[ast.AST, ...] = () -) -> Iterator[tuple[tuple[ast.AST, ...], ast.AST]]: - parents = (node,) + parents - for child in ast.iter_child_nodes(node): - yield parents, child - yield from walk_with_parent(child, parents) - - -@dataclass -class KeywordInfo: - replace: bool - keywords: list[ast.keyword] diff --git a/src/idom/utils.py b/src/idom/utils.py index 1b57bcf7c..16aef68fb 100644 --- a/src/idom/utils.py +++ b/src/idom/utils.py @@ -276,6 +276,15 @@ def _vdom_attr_to_html_str(key: str, value: Any) -> tuple[str, str]: f"{_CAMEL_CASE_SUB_PATTERN.sub('-', k).lower()}:{v}" for k, v in value.items() ) + elif ( + # camel to data-* attributes + key.startswith("data_") + # camel to aria-* attributes + or key.startswith("aria_") + # handle special cases + or key in DASHED_HTML_ATTRS + ): + key = key.replace("_", "-") elif ( # camel to data-* attributes key.startswith("data") @@ -297,7 +306,7 @@ def _vdom_attr_to_html_str(key: str, value: Any) -> tuple[str, str]: # see list of HTML attributes with dashes in them: # https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#attribute_list -DASHED_HTML_ATTRS = {"accept_charset", "http_equiv"} +DASHED_HTML_ATTRS = {"accept_charset", "acceptCharset", "http_equiv", "httpEquiv"} # Pattern for delimitting camelCase names (e.g. camelCase to camel-case) _CAMEL_CASE_SUB_PATTERN = re.compile(r"(?=3.9") + + +def test_rewrite_camel_case_props_declarations(tmp_path): + runner = CliRunner() + + tempfile: Path = tmp_path / "temp.py" + tempfile.write_text("html.div(dict(camelCase='test'))") + result = runner.invoke( + rewrite_camel_case_props, + args=[str(tmp_path)], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert tempfile.read_text() == "html.div(dict(camel_case='test'))" + + +def test_rewrite_camel_case_props_declarations_no_files(): + runner = CliRunner() + + result = runner.invoke( + rewrite_camel_case_props, + args=["directory-does-no-exist"], + catch_exceptions=False, + ) + + assert result.exit_code != 0 + + +@pytest.mark.parametrize( + "source, expected", + [ + ( + "html.div(dict(camelCase='test'))", + "html.div(dict(camel_case='test'))", + ), + ( + "vdom('tag', dict(camelCase='test'))", + "vdom('tag', dict(camel_case='test'))", + ), + ( + "html.div({'camelCase': test})", + "html.div({'camel_case': test})", + ), + ( + "html.div({'camelCase': test, ignore: this})", + "html.div({'camel_case': test, ignore: this})", + ), + # no rewrite + ( + "html.div({'snake_case': test})", + None, + ), + ( + "html.div(dict(snake_case='test'))", + None, + ), + ], + ids=lambda item: " ".join(map(str.strip, item.split())) + if isinstance(item, str) + else item, +) +def test_generate_rewrite(source, expected): + actual = generate_rewrite(Path("test.py"), dedent(source).strip()) + if isinstance(expected, str): + expected = dedent(expected).strip() + + assert actual == expected diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index b820bea2c..657d2597b 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -915,7 +915,7 @@ def Root(): @idom.component def SomeComponent(): handler = component_static_handler.use(lambda: None) - return html.button({"onAnotherEvent": handler}) + return html.button({"on_another_event": handler}) async with idom.Layout(Root()) as layout: await layout.render() @@ -998,7 +998,7 @@ def Parent(): state, set_state = use_state(0) return html.div( html.button( - {"onClick": set_child_key_num.use(lambda: set_state(state + 1))}, + {"on_click": set_child_key_num.use(lambda: set_state(state + 1))}, "click me", ), Child("some-key"), diff --git a/tests/test_html.py b/tests/test_html.py index 236a37f16..f7ac6c829 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -13,7 +13,7 @@ async def test_script_mount_unmount(display: DisplayFixture): def Root(): is_mounted, toggle_is_mounted.current = use_toggle(True) return html.div( - html.div({"id": "mount-state", "data-value": False}), + html.div({"id": "mount-state", "data_value": False}), HasScript() if is_mounted else html.div(), ) @@ -53,8 +53,8 @@ async def test_script_re_run_on_content_change(display: DisplayFixture): def HasScript(): count, incr_count.current = use_counter(1) return html.div( - html.div({"id": "mount-count", "data-value": 0}), - html.div({"id": "unmount-count", "data-value": 0}), + html.div({"id": "mount-count", "data_value": 0}), + html.div({"id": "unmount-count", "data_value": 0}), html.script( f"""() => {{ const mountCountEl = document.getElementById("mount-count"); @@ -101,7 +101,7 @@ def HasScript(): return html.div() else: return html.div( - html.div({"id": "run-count", "data-value": 0}), + html.div({"id": "run-count", "data_value": 0}), html.script( { "src": f"/_idom/modules/{file_name_template.format(src_id=src_id)}" @@ -157,4 +157,4 @@ def test_simple_fragment(): def test_fragment_can_have_no_attributes(): with pytest.raises(TypeError, match="Fragments cannot have attributes"): - html._({"some-attribute": 1}) + html._({"some_attribute": 1}) diff --git a/tests/test_testing.py b/tests/test_testing.py index 27afa980a..32532a736 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -192,7 +192,7 @@ def ButtonSwapsDivs(): async def on_click(event): mount(make_next_count_constructor(count)) - incr = html.button({"onClick": on_click, "id": "incr-button"}, "incr") + incr = html.button({"on_click": on_click, "id": "incr-button"}, "incr") mount, make_hostswap = _hotswap(update_on_change=True) mount(make_next_count_constructor(count)) diff --git a/tests/test_utils.py b/tests/test_utils.py index c09f09f16..d08148662 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -215,7 +215,7 @@ def test_del_html_body_transform(): '
helloexampleworld
', ), ( - html.button({"onClick": lambda event: None}), + html.button({"on_click": lambda event: None}), "", ), ( @@ -251,6 +251,12 @@ def test_del_html_body_transform(): ), '
hello
example
', ), + ( + html.div( + {"data_something": 1, "data_something_else": 2, "dataisnotdashed": 3} + ), + '
', + ), ( html.div( {"dataSomething": 1, "dataSomethingElse": 2, "dataisnotdashed": 3} From 564286a22918dad7a8eea9a17d9a801c81580ea9 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 21 Feb 2023 00:04:33 -0800 Subject: [PATCH 17/19] fix types --- src/idom/_console/rewrite_camel_case_props.py | 19 ++++++++++--------- src/idom/_console/rewrite_keys.py | 2 +- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/idom/_console/rewrite_camel_case_props.py b/src/idom/_console/rewrite_camel_case_props.py index 5f407b5e2..350e77895 100644 --- a/src/idom/_console/rewrite_camel_case_props.py +++ b/src/idom/_console/rewrite_camel_case_props.py @@ -46,17 +46,17 @@ def generate_rewrite(file: Path, source: str) -> str | None: def find_nodes_to_change(tree: ast.AST) -> list[ChangedNode]: - changed: list[Sequence[ast.AST]] = [] + changed: list[ChangedNode] = [] for el_info in find_element_constructor_usages(tree): if isinstance(el_info.props, ast.Dict): did_change = False - keys: list[ast.Constant] = [] + keys: list[ast.expr | None] = [] for k in el_info.props.keys: if isinstance(k, ast.Constant) and isinstance(k.value, str): new_prop_name = conv_attr_name(k.value) if new_prop_name != k.value: did_change = True - keys.append(ast.Constant(conv_attr_name(k.value))) + keys.append(ast.Constant(new_prop_name)) else: keys.append(k) else: @@ -68,12 +68,13 @@ def find_nodes_to_change(tree: ast.AST) -> list[ChangedNode]: did_change = False keywords: list[ast.keyword] = [] for kw in el_info.props.keywords: - new_prop_name = conv_attr_name(kw.arg) - if new_prop_name != kw.arg: - did_change = True - keywords.append( - ast.keyword(arg=conv_attr_name(kw.arg), value=kw.value) - ) + if kw.arg is not None: + new_prop_name = conv_attr_name(kw.arg) + if new_prop_name != kw.arg: + did_change = True + keywords.append(ast.keyword(arg=new_prop_name, value=kw.value)) + else: + keywords.append(kw) else: keywords.append(kw) if not did_change: diff --git a/src/idom/_console/rewrite_keys.py b/src/idom/_console/rewrite_keys.py index adaeaf49c..4a1019d1f 100644 --- a/src/idom/_console/rewrite_keys.py +++ b/src/idom/_console/rewrite_keys.py @@ -71,7 +71,7 @@ def generate_rewrite(file: Path, source: str) -> str | None: def find_nodes_to_change(tree: ast.AST) -> list[ChangedNode]: - changed: list[Sequence[ast.AST]] = [] + changed: list[ChangedNode] = [] for el_info in find_element_constructor_usages(tree): for kw in list(el_info.call.keywords): if kw.arg == "key": From 36b5d827c21383913f5d77782b5abd6d9a9b1087 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 21 Feb 2023 00:08:33 -0800 Subject: [PATCH 18/19] get cov --- tests/test__console/test_rewrite_camel_case_props.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test__console/test_rewrite_camel_case_props.py b/tests/test__console/test_rewrite_camel_case_props.py index 8651bf063..29bb83c64 100644 --- a/tests/test__console/test_rewrite_camel_case_props.py +++ b/tests/test__console/test_rewrite_camel_case_props.py @@ -53,6 +53,10 @@ def test_rewrite_camel_case_props_declarations_no_files(): "vdom('tag', dict(camelCase='test'))", "vdom('tag', dict(camel_case='test'))", ), + ( + "vdom('tag', dict(camelCase='test', **props))", + "vdom('tag', dict(camel_case='test', **props))", + ), ( "html.div({'camelCase': test})", "html.div({'camel_case': test})", From 76234d66a1e39792787c7d9978d2759a748e7b61 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 21 Feb 2023 00:17:24 -0800 Subject: [PATCH 19/19] fix style --- src/idom/_console/rewrite_camel_case_props.py | 2 -- src/idom/_console/rewrite_keys.py | 1 - 2 files changed, 3 deletions(-) diff --git a/src/idom/_console/rewrite_camel_case_props.py b/src/idom/_console/rewrite_camel_case_props.py index 350e77895..ff1e361bd 100644 --- a/src/idom/_console/rewrite_camel_case_props.py +++ b/src/idom/_console/rewrite_camel_case_props.py @@ -3,13 +3,11 @@ import ast import re import sys -from collections.abc import Sequence from keyword import kwlist from pathlib import Path import click -from idom import html from idom._console.ast_utils import ( ChangedNode, find_element_constructor_usages, diff --git a/src/idom/_console/rewrite_keys.py b/src/idom/_console/rewrite_keys.py index 4a1019d1f..ad2b10e72 100644 --- a/src/idom/_console/rewrite_keys.py +++ b/src/idom/_console/rewrite_keys.py @@ -2,7 +2,6 @@ import ast import sys -from collections.abc import Sequence from pathlib import Path import click