diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b679d4f6f..673bf9b32 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,7 @@ jobs: session-name: test_python_suite session-arguments: --maxfail=3 --no-cov runs-on-array: '["ubuntu-latest", "macos-latest", "windows-latest"]' - python-version-array: '["3.7", "3.8", "3.9", "3.10"]' + python-version-array: '["3.7", "3.8", "3.9", "3.10", "3.11"]' docs: uses: ./.github/workflows/.nox-session.yml with: diff --git a/docs/examples.py b/docs/examples.py index 7b9a5160a..5b273503e 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({"class": "printout"}, text) if text else idom.html.div() + return idom.html.pre(text, class_name="printout") 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 ed1027145..ee2c660bf 100644 --- a/docs/source/_custom_js/package-lock.json +++ b/docs/source/_custom_js/package-lock.json @@ -19,12 +19,12 @@ } }, "../../../src/client/packages/idom-client-react": { - "version": "0.42.0", + "version": "0.43.0", "integrity": "sha512-pIK5eNwFSHKXg7ClpASWFVKyZDYxz59MSFpVaX/OqJFkrJaAxBuhKGXNTMXmuyWOL5Iyvb/ErwwDRxQRzMNkfQ==", "license": "MIT", "dependencies": { - "fast-json-patch": "^3.0.0-1", - "htm": "^3.0.3" + "htm": "^3.0.3", + "json-pointer": "^0.6.2" }, "devDependencies": { "jsdom": "16.5.0", @@ -37,11 +37,6 @@ "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", @@ -602,19 +597,14 @@ "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 573bc35dd..7bb659230 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, Dict, cast +from typing import Any, cast from docutils import nodes from docutils.nodes import Node @@ -30,7 +30,6 @@ 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): @@ -67,7 +66,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/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..9c1c301e8 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({"onClick": handle_click}, "Next"), + html.button("Next", on_click=handle_click), 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 08a53d1c6..e235856b9 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("Next", on_click=handle_next_click), 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( - {"onClick": handle_more_click}, - f"{'Show' if show_more else 'Hide'} details", + f"{('Show' if show_more else 'Hide')} details", + on_click=handle_more_click, ), (html.p(description) if show_more else ""), ), @@ -46,8 +46,8 @@ def handle_more_click(event): @component def App(): return html.div( - html.section({"style": {"width": "50%", "float": "left"}}, Gallery()), - html.section({"style": {"width": "50%", "float": "left"}}, Gallery()), + html.section(Gallery(), style={"width": "50%", "float": "left"}), + html.section(Gallery(), style={"width": "50%", "float": "left"}), ) 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..e9a9b5648 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("Next", on_click=handle_next_click), 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( - {"onClick": handle_more_click}, - f"{'Show' if show_more else 'Hide'} details", + f"{('Show' if show_more else 'Hide')} details", + on_click=handle_more_click, ), (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..5647418b2 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({"onClick": handle_click}, "Next"), + html.button("Next", on_click=handle_click), 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 cf1955301..03444e250 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,26 +26,21 @@ def handle_click(event): return handle_click return html.div( - html.button({"onClick": handle_add_click}, "add term"), + html.button("add term", on_click=handle_add_click), 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, - } + value=definition_to_add, on_change=handle_definition_to_add_change ), ), html.hr(), [ html.div( - html.button( - {"onClick": make_delete_click_handler(term)}, "delete term" - ), + html.button("delete term", on_click=make_delete_click_handler(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 92085c0b6..d9dd35dff 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,21 +23,15 @@ def handle_email_change(event): return html.div( html.label( "First name: ", - html.input( - {"value": person["first_name"], "onChange": handle_first_name_change}, - ), + html.input(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}, - ), + html.input(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 374198f45..35cf2ce5d 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("add", on_click=handle_click), 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 32a0e47c0..e8ab5f7e1 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("sort", on_click=handle_sort_click), + html.button("reverse", on_click=handle_reverse_click), 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 170deb2f4..f37c2ff9f 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, "onChange": handle_change}), - html.button({"onClick": handle_add_click}, "add"), + html.input(value=artist_to_add, on_change=handle_change), + html.button("add", on_click=handle_add_click), html.ul( [ html.li( name, - html.button({"onClick": make_handle_delete_click(index)}, "delete"), + html.button("delete", on_click=make_handle_delete_click(index)), 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..7cfcd0bf3 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({"onClick": make_increment_click_handler(index)}, "+1"), + html.button("+1", on_click=make_increment_click_handler(index)), 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 d3edb8590..58e0b386f 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,29 +16,25 @@ 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", - "backgroundColor": "red", - "borderRadius": "50%", - "width": "20px", - "height": "20px", - "left": "-10px", - "top": "-10px", - "transform": f"translate({position['x']}px, {position['y']}px)", - }, + 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)", } ), + 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 90885e7fe..3f8f738db 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,29 +14,25 @@ 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", - "backgroundColor": "red", - "borderRadius": "50%", - "width": "20px", - "height": "20px", - "left": "-10px", - "top": "-10px", - "transform": f"translate({position['x']}px, {position['y']}px)", - }, + 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)", } ), + 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 4c4905350..b2d830909 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,25 +16,23 @@ def handle_click(event): return handle_click return html.div( - {"style": {"display": "flex", "flex-direction": "row"}}, [ html.div( - { - "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, + 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", + }, ) 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 9e1077cb0..37bd7a591 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,25 +13,23 @@ def handle_click(event): return handle_click return html.div( - {"style": {"display": "flex", "flex-direction": "row"}}, [ html.div( - { - "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, + 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", + }, ) 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 0c000477e..6b24f0110 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("Increment", on_click=handle_click), ) 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..062e57e19 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("Increment", on_click=handle_click), ) 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..313035a6e 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,11 +15,9 @@ def handle_reset(event): return html.div( html.button( - {"onClick": handle_click, "style": {"backgroundColor": color}}, "Set Color" - ), - html.button( - {"onClick": handle_reset, "style": {"backgroundColor": color}}, "Reset" + "Set Color", on_click=handle_click, style={"background_color": color} ), + 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 ec3193de9..36cb5b395 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("Increment", on_click=handle_click), ) 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 582588a8c..e6e3ab543 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,11 +8,9 @@ def PlayDinosaurSound(): event, set_event = idom.hooks.use_state(None) return idom.html.div( idom.html.audio( - { - "controls": True, - "onTimeUpdate": lambda e: set_event(e), - "src": "https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", - } + controls=True, + on_time_update=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 a355f6142..a11b3a40c 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(message, on_click=handle_event) @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..4a576123b 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(display_text, on_click=on_click) @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..bb4ac6b73 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("Click me!", on_click=handle_event) 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..4be2fc1d4 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(display_text, on_click=handle_event) @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..cd2870a46 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,11 +6,9 @@ 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 e87bae026..4a2079d43 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,25 +8,21 @@ 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( - { - "onClick": event( - lambda event: set_inner_count(inner_count + 1), - stop_propagation=stop_propagatation, - ), - "style": {"height": "50px", "width": "50px", "backgroundColor": "blue"}, - }, + on_click=event( + lambda event: set_inner_count(inner_count + 1), + stop_propagation=stop_propagatation, + ), + style={"height": "50px", "width": "50px", "background_color": "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 5471616d4..99c3aab80 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("Increment", on_click=handle_click), ) 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..2e0df7abf 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,27 +16,24 @@ 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( - { - "value": recipient, - "onChange": lambda event: set_recipient(event["target"]["value"]), - }, - html.option({"value": "Alice"}, "Alice"), - html.option({"value": "Bob"}, "Bob"), + html.option("Alice", value="Alice"), + html.option("Bob", value="Bob"), + value=recipient, + on_change=lambda event: set_recipient(event["target"]["value"]), ), ), html.input( - { - "type": "text", - "placeholder": "Your message...", - "value": message, - "onChange": lambda event: set_message(event["target"]["value"]), - } + type="text", + placeholder="Your message...", + value=message, + on_change=lambda event: set_message(event["target"]["value"]), ), - html.button({"type": "submit"}, "Send"), + html.button("Send", type="submit"), + on_submit=handle_submit, + style={"display": "inline-grid"}, ) 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..d3f09253e 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("Increment", on_click=handle_click), ) 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..fa1bfa7d8 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,9 +9,7 @@ def App(): if is_sent: return html.div( html.h1("Message sent!"), - html.button( - {"onClick": lambda event: set_is_sent(False)}, "Send new message?" - ), + html.button("Send new message?", on_click=lambda event: set_is_sent(False)), ) @event(prevent_default=True) @@ -20,15 +18,14 @@ 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, - "onChange": lambda event: set_message(event["target"]["value"]), - } + placeholder="Your message here...", + value=message, + on_change=lambda event: set_message(event["target"]["value"]), ), - html.button({"type": "submit"}, "Send"), + html.button("Send", type="submit"), + on_submit=handle_submit, + style={"display": "inline-grid"}, ) 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..0a8318231 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("Increment", on_click=handle_click), ) 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 52ca1e617..0afb4116d 100644 --- a/docs/source/guides/creating-interfaces/html-with-idom/index.rst +++ b/docs/source/guides/creating-interfaces/html-with-idom/index.rst @@ -90,11 +90,9 @@ Additionally, instead of specifying ``style`` using a string, we use a dictionar .. testcode:: html.img( - { - "src": "https://picsum.photos/id/237/500/300", - "style": {"width": "50%", "marginLeft": "25%"}, - "alt": "Billie Holiday", - } + src="https://picsum.photos/id/237/500/300", + style={"width": "50%", "marginLeft": "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 4c512b7e6..59796e49a 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,11 +4,9 @@ @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 7eacb8f36..2d5768d58 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,11 +4,9 @@ @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 c6b92c652..f5ccc5027 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,11 +4,7 @@ @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 2ddcd1060..f0ba25e84 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 027e253bf..85b9b3ceb 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 9b0658371..619a35cff 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, "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..64bcb1aa7 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,9 +15,7 @@ def Input(label, value, set_value): def handle_change(event): set_value(event["target"]["value"]) - return html.label( - label + " ", html.input({"value": value, "onChange": handle_change}) - ) + return html.label(label + " ", html.input(value=value, on_change=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 fbf257a32..24b47580f 100644 --- a/docs/source/reference/_examples/character_movement/main.py +++ b/docs/source/reference/_examples/character_movement/main.py @@ -36,15 +36,7 @@ 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, @@ -57,13 +49,15 @@ def Scene(): } }, ), + style={"width": "200px", "height": "200px", "backgroundColor": "slategray"}, ), - 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("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"}, ) diff --git a/docs/source/reference/_examples/click_count.py b/docs/source/reference/_examples/click_count.py index 6f30ce517..491fca839 100644 --- a/docs/source/reference/_examples/click_count.py +++ b/docs/source/reference/_examples/click_count.py @@ -6,8 +6,7 @@ def ClickCount(): count, set_count = idom.hooks.use_state(0) return idom.html.button( - {"onClick": lambda event: set_count(count + 1)}, - [f"Click count: {count}"], + [f"Click count: {count}"], on_click=lambda event: set_count(count + 1) ) diff --git a/docs/source/reference/_examples/matplotlib_plot.py b/docs/source/reference/_examples/matplotlib_plot.py index 6dffb79db..e219e4e96 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({"onClick": lambda event: add_input()}, "+"), - idom.html.button({"onClick": lambda event: del_input()}, "-"), + idom.html.button("+", on_click=lambda event: add_input()), + idom.html.button("-", on_click=lambda event: del_input()), ), inputs, ) @@ -58,20 +58,10 @@ def plot(title, x, y): def poly_coef_input(index, callback): return idom.html.div( - {"style": {"margin-top": "5px"}}, - idom.html.label( - "C", - idom.html.sub(index), - " × X", - idom.html.sup(index), - ), - idom.html.input( - { - "type": "number", - "onChange": callback, - }, - ), + idom.html.label("C", idom.html.sub(index), " × X", idom.html.sup(index)), + idom.html.input(type="number", on_change=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 540082f58..4f7ce2a42 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( - {"class": "number-input-container"}, - idom.html.legend({"style": {"font-size": "medium"}}, label), + idom.html.legend(label, style={"font_size": "medium"}), 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 0d3116ac4..49f732aed 100644 --- a/docs/source/reference/_examples/slideshow.py +++ b/docs/source/reference/_examples/slideshow.py @@ -9,11 +9,9 @@ def next_image(event): set_index(index + 1) return idom.html.img( - { - "src": f"https://picsum.photos/id/{index}/800/300", - "style": {"cursor": "pointer"}, - "onClick": next_image, - } + src=f"https://picsum.photos/id/{index}/800/300", + style={"cursor": "pointer"}, + on_click=next_image, ) diff --git a/docs/source/reference/_examples/snake_game.py b/docs/source/reference/_examples/snake_game.py index 92fe054f0..cf0328ef4 100644 --- a/docs/source/reference/_examples/snake_game.py +++ b/docs/source/reference/_examples/snake_game.py @@ -21,8 +21,7 @@ def GameView(): return GameLoop(grid_size=6, block_scale=50, set_game_state=set_game_state) start_button = idom.html.button( - {"onClick": lambda event: set_game_state(GameState.play)}, - "Start", + "Start", on_click=lambda event: set_game_state(GameState.play) ) if game_state == GameState.won: @@ -40,7 +39,7 @@ def GameView(): """ ) - return idom.html.div({"className": "snake-game-menu"}, menu_style, menu) + return idom.html.div(menu_style, menu, class_name="snake-game-menu") class Direction(enum.Enum): @@ -72,7 +71,7 @@ def on_direction_change(event): if direction_vector_sum != (0, 0): direction.current = maybe_new_direction - grid_wrapper = idom.html.div({"onKeyDown": on_direction_change}, grid) + grid_wrapper = idom.html.div(grid, on_key_down=on_direction_change) assign_grid_block_color(grid, food, "blue") @@ -139,50 +138,46 @@ 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( - { - "style": { - "height": f"{block_scale}px", - "width": f"{block_scale}px", - "backgroundColor": color, - "outline": "1px solid grey", - } - }, key=key, + style={ + "height": f"{block_scale}px", + "width": f"{block_scale}px", + "background_color": color, + "outline": "1px solid grey", + }, ) def assign_grid_block_color(grid, point, color): x, y = point block = grid["children"][x]["children"][y] - block["attributes"]["style"]["backgroundColor"] = color + block["attributes"]["style"]["background_color"] = color idom.run(GameView) diff --git a/docs/source/reference/_examples/todo.py b/docs/source/reference/_examples/todo.py index 7b1f6f675..36880e39a 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({"onClick": remove_task}, idom.html.button(["x"])) + delete_button = idom.html.td(idom.html.button(["x"]), on_click=remove_task) tasks.append(idom.html.tr(task_text, delete_button)) - task_input = idom.html.input({"onKeyDown": add_new_task}) + task_input = idom.html.input(on_key_down=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 ea1b780a0..5f22490eb 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({"onClick": lambda event: dispatch("reset")}, "Reset"), - idom.html.button({"onClick": lambda event: dispatch("increment")}, "+"), - idom.html.button({"onClick": lambda event: dispatch("decrement")}, "-"), + 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")), ) diff --git a/docs/source/reference/_examples/use_state_counter.py b/docs/source/reference/_examples/use_state_counter.py index 8626a60b9..27948edec 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({"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)}, "-"), + 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)), ) diff --git a/pyproject.toml b/pyproject.toml index cc0199bcd..633c3156a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,9 @@ exclude_lines = [ '\.\.\.', "raise NotImplementedError", ] +omit = [ + "src/idom/__main__.py", +] [tool.pydocstyle] inherit = false @@ -48,7 +51,7 @@ per-file-ignores = [ "docs/*/_examples/*.py:E402", ] max-line-length = 88 -max-complexity = 18 +max-complexity = 20 select = ["B", "C", "E", "F", "W", "T4", "B9", "N", "ROH"] exclude = ["**/node_modules/*", ".eggs/*", ".tox/*"] # -- flake8-tidy-imports -- diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index 5e4835f12..061839473 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -6,4 +6,5 @@ fastjsonschema >=2.14.5 requests >=2 colorlog >=6 asgiref >=3 -lxml >= 4 +lxml >=4 +click >=8, <9 diff --git a/scripts/fix_vdom_constructor_usage.py b/scripts/fix_vdom_constructor_usage.py new file mode 100644 index 000000000..97ee13d3a --- /dev/null +++ b/scripts/fix_vdom_constructor_usage.py @@ -0,0 +1,370 @@ +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 2300d6d8b..334ca9019 100644 --- a/src/client/packages/idom-client-react/src/element-utils.js +++ b/src/client/packages/idom-client-react/src/element-utils.js @@ -22,18 +22,14 @@ export function createElementAttributes(model, sendEvent) { if (model.eventHandlers) { for (const [eventName, eventSpec] of Object.entries(model.eventHandlers)) { - attributes[eventName] = createEventHandler( - eventName, - sendEvent, - eventSpec - ); + attributes[eventName] = createEventHandler(sendEvent, eventSpec); } } - return attributes; + return Object.fromEntries(Object.entries(attributes).map(normalizeAttribute)); } -function createEventHandler(eventName, sendEvent, eventSpec) { +function createEventHandler(sendEvent, eventSpec) { return function () { const data = Array.from(arguments).map((value) => { if (typeof value === "object" && value.nativeEvent) { @@ -51,7 +47,36 @@ function createEventHandler(eventName, sendEvent, eventSpec) { sendEvent({ data: data, target: eventSpec["target"], - type: "layout-event", }); }; } + +function normalizeAttribute([key, value]) { + let normKey = key; + let normValue = value; + + if (key === "style" && typeof value === "object") { + normValue = Object.fromEntries( + Object.entries(value).map(([k, v]) => [snakeToCamel(k), v]) + ); + } else if ( + key.startsWith("data_") || + key.startsWith("aria_") || + DASHED_HTML_ATTRS.includes(key) + ) { + normKey = key.replace("_", "-"); + } else { + normKey = snakeToCamel(key); + } + return [normKey, normValue]; +} + +function snakeToCamel(str) { + return str.replace(/([_][a-z])/g, (group) => + group.toUpperCase().replace("_", "") + ); +} + +// see list of HTML attributes with dashes in them: +// https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#attribute_list +const DASHED_HTML_ATTRS = ["accept_charset", "http_equiv"]; diff --git a/src/client/public/assets/idom-logo-square-small.svg b/src/client/public/assets/idom-logo-square-small.svg new file mode 100644 index 000000000..eb36c7b11 --- /dev/null +++ b/src/client/public/assets/idom-logo-square-small.svg @@ -0,0 +1,45 @@ + + + + diff --git a/src/idom/__main__.py b/src/idom/__main__.py new file mode 100644 index 000000000..d8d07aa66 --- /dev/null +++ b/src/idom/__main__.py @@ -0,0 +1,17 @@ +import click + +import idom +from idom._console.update_html_usages import update_html_usages + + +@click.group() +@click.version_option(idom.__version__, prog_name=idom.__name__) +def app() -> None: + pass + + +app.add_command(update_html_usages) + + +if __name__ == "__main__": + app() diff --git a/src/idom/_console/__init__.py b/src/idom/_console/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/idom/_console/update_html_usages.py b/src/idom/_console/update_html_usages.py new file mode 100644 index 000000000..f824df56f --- /dev/null +++ b/src/idom/_console/update_html_usages.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +import ast +import re +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 +from tokenize import generate_tokens +from typing import Iterator + +import click + +from idom import html + + +CAMEL_CASE_SUB_PATTERN = re.compile(r"(? 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 + constructor functions. For example: + + >>> html.div({"className": "x"}, "y") + {"tagName": "div", "attributes": {"className": "x"}, "children": ["y"]} + + The latest API though allows for attributes to be passed as snake_cased keyword + arguments instead. The above example would be rewritten as: + + >>> html.div("y", class_name="x") + {"tagName": "div", "attributes": {"class_name": "x"}, "children": ["y"]} + + All snake_case attributes are converted to camelCase by the client where necessary. + + ----- Notes ----- + + While this command does it's best to preserve as much of the original code as + possible, there are inevitably some limitations in doing this. As a result, we + recommend running your code formatter like Black against your code after executing + this command. + + Additionally, We are unable to perserve the location of comments that lie within any + rewritten code. This command will place the comments in the code it plans to rewrite + just above its changes. As such it requires manual intervention to put those + comments back in their original location. + """ + 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: 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): + name = func.attr + elif isinstance(func, ast.Name): + name = func.id + else: + continue + + 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:] + 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:] + 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.args = new_args + changed.append((node, *parents)) + + if not changed: + return None + + 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 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 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 + + +@dataclass +class KeywordInfo: + replace: bool + keywords: list[ast.keyword] diff --git a/src/idom/_option.py b/src/idom/_option.py index d9d8e9d79..50df79b11 100644 --- a/src/idom/_option.py +++ b/src/idom/_option.py @@ -1,11 +1,10 @@ from __future__ import annotations import os -from inspect import currentframe from logging import getLogger -from types import FrameType -from typing import Any, Callable, Generic, Iterator, TypeVar, cast -from warnings import warn +from typing import Any, Callable, Generic, TypeVar, cast + +from idom._warnings import warn _O = TypeVar("_O") @@ -129,25 +128,5 @@ def current(self) -> _O: warn( self._deprecation_message, DeprecationWarning, - stacklevel=_frame_depth_in_module() + 1, ) return super().current - - -def _frame_depth_in_module() -> int: - depth = 0 - for frame in _iter_frames(2): - if frame.f_globals.get("__name__") != __name__: - break - depth += 1 - return depth - - -def _iter_frames(index: int = 1) -> Iterator[FrameType]: - frame = currentframe() - while frame is not None: - if index == 0: - yield frame - else: - index -= 1 - frame = frame.f_back diff --git a/src/idom/_warnings.py b/src/idom/_warnings.py new file mode 100644 index 000000000..827b66be0 --- /dev/null +++ b/src/idom/_warnings.py @@ -0,0 +1,31 @@ +from functools import wraps +from inspect import currentframe +from types import FrameType +from typing import Any, Iterator +from warnings import warn as _warn + + +@wraps(_warn) +def warn(*args: Any, **kwargs: Any) -> Any: + # warn at call site outside of IDOM + _warn(*args, stacklevel=_frame_depth_in_module() + 1, **kwargs) # type: ignore + + +def _frame_depth_in_module() -> int: + depth = 0 + for frame in _iter_frames(2): + module_name = frame.f_globals.get("__name__") + if not module_name or not module_name.startswith("idom."): + break + depth += 1 + return depth + + +def _iter_frames(index: int = 1) -> Iterator[FrameType]: + frame = currentframe() + while frame is not None: + if index == 0: + yield frame + else: + index -= 1 + frame = frame.f_back diff --git a/src/idom/backend/_common.py b/src/idom/backend/_common.py index dd7916353..f1db24818 100644 --- a/src/idom/backend/_common.py +++ b/src/idom/backend/_common.py @@ -113,11 +113,9 @@ 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/core/component.py b/src/idom/core/component.py index ff4e4c655..a7d4248e3 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, Dict, Optional, Tuple +from typing import Any, Callable, Optional 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 53bce9087..578c54b0a 100644 --- a/src/idom/core/hooks.py +++ b/src/idom/core/hooks.py @@ -8,9 +8,7 @@ Any, Awaitable, Callable, - Dict, Generic, - List, NewType, Optional, Sequence, @@ -625,7 +623,7 @@ def __init__( self._rendered_atleast_once = False self._current_state_index = 0 self._state: Tuple[Any, ...] = () - self._event_effects: Dict[EffectType, List[Callable[[], None]]] = { + self._event_effects: dict[EffectType, list[Callable[[], None]]] = { COMPONENT_DID_RENDER_EFFECT: [], LAYOUT_DID_RENDER_EFFECT: [], COMPONENT_WILL_UNMOUNT_EFFECT: [], diff --git a/src/idom/core/layout.py b/src/idom/core/layout.py index a0bbad042..4338ae0fd 100644 --- a/src/idom/core/layout.py +++ b/src/idom/core/layout.py @@ -8,15 +8,11 @@ from typing import ( Any, Callable, - Dict, Generic, Iterator, - List, NamedTuple, NewType, Optional, - Set, - Tuple, TypeVar, cast, ) @@ -217,7 +213,7 @@ def _render_model_attributes( self, old_state: Optional[_ModelState], new_state: _ModelState, - raw_model: Dict[str, Any], + raw_model: dict[str, Any], ) -> None: # extract event handlers from 'eventHandlers' and 'attributes' handlers_by_event: EventHandlerDict = raw_model.get("eventHandlers", {}) @@ -385,7 +381,7 @@ def _render_model_children_without_old_state( self, exit_stack: ExitStack, new_state: _ModelState, - raw_children: List[Any], + raw_children: list[Any], ) -> None: child_type_key_tuples = list(_process_child_type_and_key(raw_children)) @@ -412,7 +408,7 @@ def _render_model_children_without_old_state( else: new_state.append_child(child) - def _unmount_model_states(self, old_states: List[_ModelState]) -> None: + def _unmount_model_states(self, old_states: list[_ModelState]) -> None: to_unmount = old_states[::-1] # unmount in reversed order of rendering while to_unmount: model_state = to_unmount.pop() @@ -561,8 +557,8 @@ def __init__( key: Any, model: Ref[VdomJson], patch_path: str, - children_by_key: Dict[str, _ModelState], - targets_by_event: Dict[str, str], + children_by_key: dict[str, _ModelState], + targets_by_event: dict[str, str], life_cycle_state: Optional[_LifeCycleState] = None, ): self.index = index @@ -661,7 +657,7 @@ class _ThreadSafeQueue(Generic[_Type]): def __init__(self) -> None: self._loop = asyncio.get_running_loop() self._queue: asyncio.Queue[_Type] = asyncio.Queue() - self._pending: Set[_Type] = set() + self._pending: set[_Type] = set() def put(self, value: _Type) -> None: if value not in self._pending: @@ -679,8 +675,8 @@ async def get(self) -> _Type: def _process_child_type_and_key( - children: List[Any], -) -> Iterator[Tuple[Any, _ElementType, Any]]: + children: list[Any], +) -> Iterator[tuple[Any, _ElementType, Any]]: for index, child in enumerate(children): if isinstance(child, dict): child_type = _DICT_TYPE diff --git a/src/idom/core/types.py b/src/idom/core/types.py index 0fd78ec22..38a17481a 100644 --- a/src/idom/core/types.py +++ b/src/idom/core/types.py @@ -2,25 +2,22 @@ import sys from collections import namedtuple -from collections.abc import Sequence from types import TracebackType from typing import ( TYPE_CHECKING, Any, Callable, - Dict, Generic, - Iterable, - List, Mapping, NamedTuple, Optional, + Sequence, Type, TypeVar, Union, ) -from typing_extensions import Literal, Protocol, TypedDict, runtime_checkable +from typing_extensions import Literal, Protocol, TypeAlias, TypedDict, runtime_checkable _Type = TypeVar("_Type") @@ -62,10 +59,13 @@ class ComponentType(Protocol): This is used to see if two component instances share the same definition. """ - def render(self) -> VdomDict | ComponentType | str | None: + def render(self) -> RenderResult: """Render the component's view model.""" +RenderResult = Union["VdomDict", ComponentType, str, None] + + _Render = TypeVar("_Render", covariant=True) _Event = TypeVar("_Event", contravariant=True) @@ -98,23 +98,16 @@ async def __aexit__( VdomChild = Union[ComponentType, "VdomDict", str] """A single child element of a :class:`VdomDict`""" -VdomChildren = "Sequence[VdomChild]" +VdomChildren = Sequence[VdomChild] """Describes a series of :class:`VdomChild` elements""" -VdomAttributesAndChildren = Union[ - Mapping[str, Any], # this describes both VdomDict and VdomAttributes - Iterable[VdomChild], - VdomChild, -] -"""Useful for the ``*attributes_and_children`` parameter in :func:`idom.core.vdom.vdom`""" - class _VdomDictOptional(TypedDict, total=False): key: Key | None children: Sequence[ # recursive types are not allowed yet: # https://github.com/python/mypy/issues/731 - Union[ComponentType, Dict[str, Any], str, Any] + Union[ComponentType, dict[str, Any], str, Any] ] attributes: VdomAttributes eventHandlers: EventHandlerDict # noqa @@ -139,9 +132,9 @@ class ImportSourceDict(TypedDict): class _OptionalVdomJson(TypedDict, total=False): key: Key error: str - children: List[Any] - attributes: Dict[str, Any] - eventHandlers: Dict[str, _JsonEventTarget] # noqa + children: list[Any] + attributes: dict[str, Any] + eventHandlers: dict[str, _JsonEventTarget] # noqa importSource: _JsonImportSource # noqa @@ -167,7 +160,7 @@ class _JsonImportSource(TypedDict): EventHandlerMapping = Mapping[str, "EventHandlerType"] """A generic mapping between event names to their handlers""" -EventHandlerDict = Dict[str, "EventHandlerType"] +EventHandlerDict: TypeAlias = "dict[str, EventHandlerType]" """A dict mapping between event names to their handlers""" @@ -208,9 +201,9 @@ class VdomDictConstructor(Protocol): def __call__( self, - *attributes_and_children: VdomAttributesAndChildren, - key: str = ..., - event_handlers: Optional[EventHandlerMapping] = ..., + *children: VdomChild | VdomChildren, + key: Key | None = None, + **attributes: Any, ) -> VdomDict: ... diff --git a/src/idom/core/vdom.py b/src/idom/core/vdom.py index 78bfb3725..6b4d5c5d4 100644 --- a/src/idom/core/vdom.py +++ b/src/idom/core/vdom.py @@ -1,11 +1,12 @@ from __future__ import annotations import logging -from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple, cast +from typing import Any, DefaultDict, Mapping, cast from fastjsonschema import compile as compile_json_schema -from typing_extensions import Protocol +from typing_extensions import TypeGuard +from idom._warnings import warn from idom.config import IDOM_DEBUG_MODE from idom.core.events import ( EventHandler, @@ -15,11 +16,13 @@ from idom.core.types import ( ComponentType, EventHandlerDict, - EventHandlerMapping, EventHandlerType, ImportSourceDict, - VdomAttributesAndChildren, + Key, + VdomChild, + VdomChildren, VdomDict, + VdomDictConstructor, VdomJson, ) @@ -130,12 +133,11 @@ def is_vdom(value: Any) -> bool: def vdom( tag: str, - *attributes_and_children: VdomAttributesAndChildren, - key: str | int | None = None, - event_handlers: Optional[EventHandlerMapping] = None, - import_source: Optional[ImportSourceDict] = None, + *children: VdomChild | VdomChildren, + key: Key | None = None, + **attributes: Any, ) -> VdomDict: - """A helper function for creating VDOM dictionaries. + """A helper function for creating VDOM elements. Parameters: tag: @@ -157,16 +159,43 @@ def vdom( """ model: VdomDict = {"tagName": tag} - attributes, children = coalesce_attributes_and_children(attributes_and_children) - attributes, event_handlers = separate_attributes_and_event_handlers( - attributes, event_handlers or {} - ) + flattened_children: list[VdomChild] = [] + for child in children: + if isinstance(child, dict) and "tagName" not in child: # pragma: no cover + 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" + ), + 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 + + attributes, event_handlers = separate_attributes_and_event_handlers(attributes) if attributes: model["attributes"] = attributes - if children: - model["children"] = children + if flattened_children: + model["children"] = flattened_children if event_handlers: model["eventHandlers"] = event_handlers @@ -174,26 +203,18 @@ def vdom( if key is not None: model["key"] = key - if import_source is not None: - model["importSource"] = import_source - return model -class _VdomDictConstructor(Protocol): - def __call__( - self, - *attributes_and_children: VdomAttributesAndChildren, - key: str | int | None = ..., - event_handlers: Optional[EventHandlerMapping] = ..., - import_source: Optional[ImportSourceDict] = ..., - ) -> VdomDict: - ... +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 -) -> _VdomDictConstructor: + tag: str, + allow_children: bool = True, + import_source: ImportSourceDict | None = None, +) -> VdomDictConstructor: """Return a constructor for VDOM dictionaries with the given tag name. The resulting callable will have the same interface as :func:`vdom` but without its @@ -201,20 +222,17 @@ def make_vdom_constructor( """ def constructor( - *attributes_and_children: VdomAttributesAndChildren, - key: str | int | None = None, - event_handlers: Optional[EventHandlerMapping] = None, - import_source: Optional[ImportSourceDict] = None, + *children: VdomChild | VdomChildren, + key: Key | None = None, + **attributes: Any, ) -> VdomDict: - model = vdom( - tag, - *attributes_and_children, - key=key, - event_handlers=event_handlers, - import_source=import_source, - ) - if not allow_children and "children" in model: + if not allow_children and children: 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) + return model # replicate common function attributes @@ -233,36 +251,11 @@ def constructor( return constructor -def coalesce_attributes_and_children( - values: Sequence[Any], -) -> Tuple[Mapping[str, Any], List[Any]]: - if not values: - return {}, [] - - children_or_iterables: Sequence[Any] - attributes, *children_or_iterables = values - if not _is_attributes(attributes): - 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], event_handlers: EventHandlerMapping -) -> Tuple[Dict[str, Any], EventHandlerDict]: + attributes: Mapping[str, Any] +) -> tuple[dict[str, Any], EventHandlerDict]: separated_attributes = {} - separated_event_handlers: Dict[str, List[EventHandlerType]] = {} - - for k, v in event_handlers.items(): - separated_event_handlers[k] = [v] + separated_handlers: DefaultDict[str, list[EventHandlerType]] = DefaultDict(list) for k, v in attributes.items(): @@ -271,7 +264,8 @@ def separate_attributes_and_event_handlers( if callable(v): handler = EventHandler(to_event_handler_function(v)) elif ( - # isinstance check on protocols is slow, function attr check is a quick filter + # isinstance check on protocols is slow - use function attr pre-check as a + # quick filter before actually performing slow EventHandlerType type check hasattr(v, "function") and isinstance(v, EventHandlerType) ): @@ -280,23 +274,16 @@ def separate_attributes_and_event_handlers( separated_attributes[k] = v continue - if k not in separated_event_handlers: - separated_event_handlers[k] = [handler] - else: - separated_event_handlers[k].append(handler) + separated_handlers[k].append(handler) flat_event_handlers_dict = { - k: merge_event_handlers(h) for k, h in separated_event_handlers.items() + 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) -> bool: +def _is_single_child(value: Any) -> TypeGuard[VdomChild]: 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 964af5d6e..8b4b3edfd 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 Any from idom.core.types import Key, VdomDict -from idom.core.vdom import coalesce_attributes_and_children, make_vdom_constructor +from idom.core.vdom import make_vdom_constructor, vdom __all__ = ( @@ -280,18 +280,7 @@ def _(*children: Any, key: Key | None = None) -> VdomDict: """An HTML fragment - this element will not appear in the DOM""" - attributes, coalesced_children = coalesce_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 + return vdom("", *children, key=key) # Dcument metadata @@ -389,10 +378,7 @@ def _(*children: Any, key: Key | None = None) -> VdomDict: noscript = make_vdom_constructor("noscript") -def script( - *attributes_and_children: Mapping[str, Any] | str, - key: str | int | None = None, -) -> VdomDict: +def script(*children: str, key: Key | None = None, **attributes: Any) -> VdomDict: """Create a new `<{script}>Hello World.
' expected = { - "attributes": {"style": {"backgroundColor": "green", "color": "red"}}, + "attributes": {"style": {"background_color": "green", "color": "red"}}, "children": ["Hello World."], "tagName": "p", } @@ -205,17 +213,15 @@ def test_del_html_body_transform(): f"