From 0e5dbc4e3b5d249db177837f3d2d6b63f67833e6 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Tue, 24 Jan 2023 20:59:22 -0800 Subject: [PATCH 01/24] begin vdom interface unification --- scripts/fix_vdom_constructor_usage.py | 370 ++++++++++++++++++ .../idom-client-react/src/element-utils.js | 23 +- src/idom/core/types.py | 11 +- src/idom/core/vdom.py | 100 ++--- src/idom/html.py | 31 +- 5 files changed, 421 insertions(+), 114 deletions(-) create mode 100644 scripts/fix_vdom_constructor_usage.py 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..afeb7a62e 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,16 @@ 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(([key, value]) => [snakeToCamel(key), value]) + ); } -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 +49,16 @@ function createEventHandler(eventName, sendEvent, eventSpec) { sendEvent({ data: data, target: eventSpec["target"], - type: "layout-event", }); }; } + +function snakeToCamel(str) { + if (str.startsWith("data_") || str.startsWith("aria_")) { + return str.replace("_", "-"); + } else { + return str + .toLowerCase() + .replace(/([-_][a-z])/g, (group) => group.toUpperCase().replace("_", "")); + } +} diff --git a/src/idom/core/types.py b/src/idom/core/types.py index 0fd78ec22..0beab45ec 100644 --- a/src/idom/core/types.py +++ b/src/idom/core/types.py @@ -62,10 +62,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) @@ -208,9 +211,9 @@ class VdomDictConstructor(Protocol): def __call__( self, - *attributes_and_children: VdomAttributesAndChildren, - key: str = ..., - event_handlers: Optional[EventHandlerMapping] = ..., + *children: VdomChild, + key: Key | None = None, + **attributes: Any, ) -> VdomDict: ... diff --git a/src/idom/core/vdom.py b/src/idom/core/vdom.py index 78bfb3725..eb10da890 100644 --- a/src/idom/core/vdom.py +++ b/src/idom/core/vdom.py @@ -1,10 +1,9 @@ from __future__ import annotations import logging -from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple, cast +from typing import Any, Mapping, cast from fastjsonschema import compile as compile_json_schema -from typing_extensions import Protocol from idom.config import IDOM_DEBUG_MODE from idom.core.events import ( @@ -15,11 +14,12 @@ from idom.core.types import ( ComponentType, EventHandlerDict, - EventHandlerMapping, EventHandlerType, ImportSourceDict, - VdomAttributesAndChildren, + Key, + VdomChild, VdomDict, + VdomDictConstructor, VdomJson, ) @@ -129,13 +129,9 @@ 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, + tag: str, *children: VdomChild, key: Key | None = None, **attributes: Any ) -> VdomDict: - """A helper function for creating VDOM dictionaries. + """A helper function for creating VDOM elements. Parameters: tag: @@ -157,10 +153,14 @@ 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 {} - ) + children: list[VdomChild] = [] + for child in children: + if _is_single_child(child): + children.append(child) + else: + children.extend(child) + + attributes, event_handlers = separate_attributes_and_event_handlers(attributes) if attributes: model["attributes"] = attributes @@ -174,26 +174,14 @@ 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} -def make_vdom_constructor( - tag: str, allow_children: bool = True -) -> _VdomDictConstructor: +def make_vdom_constructor(tag: str, allow_children: bool = True) -> 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,21 +189,11 @@ 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, 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.") - return model + return vdom(tag, *children, key=key, **attributes) # replicate common function attributes constructor.__name__ = tag @@ -233,36 +211,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_event_handlers: dict[str, list[EventHandlerType]] = {} for k, v in attributes.items(): @@ -271,7 +224,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) ): @@ -292,10 +246,6 @@ def separate_attributes_and_event_handlers( 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: if isinstance(value, (str, Mapping)) or not hasattr(value, "__iter__"): return True diff --git a/src/idom/html.py b/src/idom/html.py index 964af5d6e..a2174063a 100644 --- a/src/idom/html.py +++ b/src/idom/html.py @@ -160,7 +160,7 @@ from typing import Any, Mapping 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}> `__ element. This behaves slightly differently than a normal script element in that it may be run @@ -406,29 +392,20 @@ def script( 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 = coalesce_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"] - if key is not None: - model["key"] = key - - return model + return vdom("script", *children, key=key, **attributes) # Demarcating edits From 315deede15cf792f53c416ee0690622a6fbaf178 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 25 Jan 2023 18:19:32 -0800 Subject: [PATCH 02/24] turn script into cli app --- requirements/pkg-deps.txt | 3 +- src/idom/__main__.py | 14 ++ src/idom/_console/__init__.py | 0 src/idom/_console/update_html_usages.py | 207 ++++++++++++++++++ tests/test__console/__init__.py | 0 .../test__console/test_update_html_usages.py | 193 ++++++++++++++++ 6 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 src/idom/__main__.py create mode 100644 src/idom/_console/__init__.py create mode 100644 src/idom/_console/update_html_usages.py create mode 100644 tests/test__console/__init__.py create mode 100644 tests/test__console/test_update_html_usages.py diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index 5e4835f12..398057266 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 +typer >=8, <9 diff --git a/src/idom/__main__.py b/src/idom/__main__.py new file mode 100644 index 000000000..a2927e204 --- /dev/null +++ b/src/idom/__main__.py @@ -0,0 +1,14 @@ +import click + +from idom import __version__ +from idom._console.update_html_usages import update_html_usages + + +app = click.Group( + commands=[ + 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..34402068d --- /dev/null +++ b/src/idom/_console/update_html_usages.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import ast +import re +from collections.abc import Sequence +from glob import iglob +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 matching the given glob patterns to 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. + """ + for pat in patterns: + for file in map(Path, iglob(pat)): + result = generate_rewrite(file=file, source=file.read_text()) + if result is not None: + file.write_text(result) + + +def generate_rewrite(file: Path, source: str) -> 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))): + click.echo(f"Moved comments to {file}:{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 diff --git a/tests/test__console/__init__.py b/tests/test__console/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test__console/test_update_html_usages.py b/tests/test__console/test_update_html_usages.py new file mode 100644 index 000000000..0ee6686c0 --- /dev/null +++ b/tests/test__console/test_update_html_usages.py @@ -0,0 +1,193 @@ +from pathlib import Path +from textwrap import dedent + +import pytest +from click.testing import CliRunner + +from idom._console.update_html_usages import generate_rewrite, update_html_usages + + +def test_update_html_usages(tmp_path): + runner = CliRunner() + + tempfile: Path = tmp_path / "temp.py" + tempfile.write_text("html.div({'className': test})") + + result = runner.invoke(update_html_usages, str(tempfile)) + + if result.exception: + raise result.exception + + assert result.exit_code == 0 + assert tempfile.read_text() == "html.div(class_name=test)" + + +@pytest.mark.parametrize( + "source, expected", + [ + ( + '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') + """, + ), + ], +) +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 From 19d456486dfea6a8d84b1e23b03b69df8d2b9450 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 25 Jan 2023 19:09:09 -0800 Subject: [PATCH 03/24] misc fixes --- requirements/pkg-deps.txt | 2 +- src/idom/_console/update_html_usages.py | 12 +++++----- src/idom/core/vdom.py | 23 +++++++++++++++---- .../test__console/test_update_html_usages.py | 2 +- 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index 398057266..061839473 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -7,4 +7,4 @@ requests >=2 colorlog >=6 asgiref >=3 lxml >=4 -typer >=8, <9 +click >=8, <9 diff --git a/src/idom/_console/update_html_usages.py b/src/idom/_console/update_html_usages.py index 34402068d..4d66837f7 100644 --- a/src/idom/_console/update_html_usages.py +++ b/src/idom/_console/update_html_usages.py @@ -3,7 +3,7 @@ import ast import re from collections.abc import Sequence -from glob import iglob +from glob import glob from keyword import kwlist from pathlib import Path from textwrap import indent @@ -20,9 +20,9 @@ @click.command() -@click.argument("patterns", nargs=-1) -def update_html_usages(patterns: list[str]) -> None: - """Rewrite files matching the given glob patterns to the new html element API. +@click.argument("directories", nargs=-1) +def update_html_usages(directories: list[str]) -> None: + """Rewrite files in the given directories to use the new html element API. The old API required users to pass a dictionary of attributes to html element constructor functions. For example: @@ -50,8 +50,8 @@ def update_html_usages(patterns: list[str]) -> None: just above its changes. As such it requires manual intervention to put those comments back in their original location. """ - for pat in patterns: - for file in map(Path, iglob(pat)): + for d in directories: + for file in Path(d).rglob("*.py"): result = generate_rewrite(file=file, source=file.read_text()) if result is not None: file.write_text(result) diff --git a/src/idom/core/vdom.py b/src/idom/core/vdom.py index eb10da890..40340367b 100644 --- a/src/idom/core/vdom.py +++ b/src/idom/core/vdom.py @@ -2,6 +2,7 @@ import logging from typing import Any, Mapping, cast +from warnings import warn from fastjsonschema import compile as compile_json_schema @@ -153,20 +154,32 @@ def vdom( """ model: VdomDict = {"tagName": tag} - children: list[VdomChild] = [] + flattened_children: list[VdomChild] = [] for child in children: + if isinstance(child, dict) and "tagName" not in child: + warn( + ( + "Element constructor signatures have changed! 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'). For " + "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): - children.append(child) + flattened_children.append(child) else: - children.extend(child) + flattened_children.extend(child) 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 diff --git a/tests/test__console/test_update_html_usages.py b/tests/test__console/test_update_html_usages.py index 0ee6686c0..58c79c958 100644 --- a/tests/test__console/test_update_html_usages.py +++ b/tests/test__console/test_update_html_usages.py @@ -13,7 +13,7 @@ def test_update_html_usages(tmp_path): tempfile: Path = tmp_path / "temp.py" tempfile.write_text("html.div({'className': test})") - result = runner.invoke(update_html_usages, str(tempfile)) + result = runner.invoke(update_html_usages, str(tmp_path)) if result.exception: raise result.exception From 07c5d2b50612f9bf71ebe143e1b1f73f850d58c6 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 25 Jan 2023 19:23:18 -0800 Subject: [PATCH 04/24] convert vdom func usages too --- src/idom/_console/update_html_usages.py | 30 ++++++++++++++----- .../test__console/test_update_html_usages.py | 8 +++++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/idom/_console/update_html_usages.py b/src/idom/_console/update_html_usages.py index 4d66837f7..fdfd5e08a 100644 --- a/src/idom/_console/update_html_usages.py +++ b/src/idom/_console/update_html_usages.py @@ -71,21 +71,31 @@ def generate_rewrite(file: Path, source: str) -> None: name = func.id case _: name = "" - if hasattr(html, name): + if hasattr(html, name) or name == "vdom": + if name == "vdom": + # first arg is the tag name + node_args_pre = node.args[:1] + node.args = node.args[1:] + else: + node_args_pre = [] + 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): + if k.value == "tagName": + # this is a vdom dict declaration + break 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)) + else: + node.args = node_args_pre + node.args[1:] + node.keywords = new_kwargs + changed.append((node, *parents)) case [ ast.Call( func=ast.Name(id="dict", ctx=ast.Load()), @@ -99,6 +109,9 @@ def generate_rewrite(file: Path, source: str) -> None: *node.keywords, ] for kw in kwargs: + if kw.arg == "tagName": + # this is a vdom dict declaration + break if kw.arg is not None: new_kwargs.append( ast.keyword( @@ -107,9 +120,10 @@ def generate_rewrite(file: Path, source: str) -> None: ) else: new_kwargs.append(kw) - node.args = node.args[1:] - node.keywords = new_kwargs - changed.append((node, *parents)) + else: + node.args = node_args_pre + node.args[1:] + node.keywords = new_kwargs + changed.append((node, *parents)) case _: pass diff --git a/tests/test__console/test_update_html_usages.py b/tests/test__console/test_update_html_usages.py index 58c79c958..a3d20fa7b 100644 --- a/tests/test__console/test_update_html_usages.py +++ b/tests/test__console/test_update_html_usages.py @@ -29,6 +29,10 @@ def test_update_html_usages(tmp_path): 'html.div({"className": "test"})', "html.div(class_name='test')", ), + ( + 'vdom("div", {"className": "test"})', + "vdom('div', class_name='test')", + ), ( 'html.div({class_name: "test", **other})', "html.div(**{class_name: 'test', **other})", @@ -68,6 +72,10 @@ def test_update_html_usages(tmp_path): 'html.div(ignore, {"className": "test"})', None, ), + ( + 'html.div({"tagName": "test"})', + None, + ), # avoid unnecessary changes ( """ From cf482051584c18fb6f30c4d7ba376450616b2b24 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 25 Jan 2023 19:34:33 -0800 Subject: [PATCH 05/24] minor fix --- src/idom/_console/update_html_usages.py | 6 +++++- tests/test__console/test_update_html_usages.py | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/idom/_console/update_html_usages.py b/src/idom/_console/update_html_usages.py index fdfd5e08a..2e1219099 100644 --- a/src/idom/_console/update_html_usages.py +++ b/src/idom/_console/update_html_usages.py @@ -82,17 +82,21 @@ def generate_rewrite(file: Path, source: str) -> None: match node.args: case [ast.Dict(keys, values), *_]: new_kwargs = list(node.keywords) + should_change = True for k, v in zip(keys, values): if isinstance(k, ast.Constant) and isinstance(k.value, str): if k.value == "tagName": # this is a vdom dict declaration + should_change = False break new_kwargs.append( ast.keyword(arg=conv_attr_name(k.value), value=v) ) else: new_kwargs = [ast.keyword(arg=None, value=node.args[0])] - else: + should_change = True + break + if should_change: node.args = node_args_pre + node.args[1:] node.keywords = new_kwargs changed.append((node, *parents)) diff --git a/tests/test__console/test_update_html_usages.py b/tests/test__console/test_update_html_usages.py index a3d20fa7b..7c73d339f 100644 --- a/tests/test__console/test_update_html_usages.py +++ b/tests/test__console/test_update_html_usages.py @@ -34,8 +34,8 @@ def test_update_html_usages(tmp_path): "vdom('div', class_name='test')", ), ( - 'html.div({class_name: "test", **other})', - "html.div(**{class_name: 'test', **other})", + 'html.div({variable: "test", **other, "key": value})', + "html.div(**{variable: 'test', **other, 'key': value})", ), ( 'html.div(dict(other, className="test"))', From c9a176d6b3dc98da3472b0491fdda0b4be661b25 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 25 Jan 2023 20:05:29 -0800 Subject: [PATCH 06/24] start fixes in source/tests/docs --- docs/examples.py | 2 +- .../_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 +- .../_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 | 45 ++++++++--------- docs/source/reference/_examples/todo.py | 4 +- .../_examples/use_reducer_counter.py | 6 +-- .../reference/_examples/use_state_counter.py | 6 +-- src/idom/backend/_common.py | 8 ++- src/idom/sample.py | 8 ++- src/idom/utils.py | 15 +++--- tests/test_backend/test__common.py | 6 +-- tests/test_backend/test_all.py | 14 +++--- tests/test_client.py | 14 +++--- 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 | 8 +-- tests/test_core/test_vdom.py | 50 ++++++------------- tests/test_html.py | 16 +++--- tests/test_testing.py | 4 +- tests/test_utils.py | 20 +++----- tests/test_web/test_module.py | 2 +- 63 files changed, 286 insertions(+), 417 deletions(-) diff --git a/docs/examples.py b/docs/examples.py index 7b9a5160a..05a4c0616 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_="printout") if text else idom.html.div() return Wrapper() 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..02a787b33 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", + "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%", + "backgroundColor": "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..ce9759382 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", + "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%", + "backgroundColor": "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..98a268d44 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", + "backgroundColor": "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..d34e74cb5 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", + "backgroundColor": "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..c04c75c86 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={"backgroundColor": color} ), + html.button("Reset", on_click=handle_reset, style={"backgroundColor": 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..e4ff7aad5 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", "backgroundColor": "blue"}, ), + on_click=lambda event: set_outer_count(outer_count + 1), + style={"height": "100px", "width": "100px", "backgroundColor": "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/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..38f8624e1 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..a22648522 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_="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..e7d6f7234 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,43 +138,39 @@ 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", + "backgroundColor": color, + "outline": "1px solid grey", + }, ) 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/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/sample.py b/src/idom/sample.py index 908de34b7..45ff87076 100644 --- a/src/idom/sample.py +++ b/src/idom/sample.py @@ -8,14 +8,12 @@ @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( - {"href": "https://pypi.org/project/idom/", "target": "_blank"}, - "here", - ), + html.a("here", href="https://pypi.org/project/idom/", target="_blank"), " to learn more.", ), + id="sample", + style={"padding": "15px"}, ) diff --git a/src/idom/utils.py b/src/idom/utils.py index 1deab122d..ea4161192 100644 --- a/src/idom/utils.py +++ b/src/idom/utils.py @@ -292,18 +292,20 @@ 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"{_CAMEL_CASE_SUB_PATTERN.sub('-', k).lower()}:{v}" + f"{k.replace('_', '-').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 = _CAMEL_CASE_SUB_PATTERN.sub("-", key) + key = key.replace("_", "-") + else: + key = key.replace("_", "") assert not callable( value @@ -314,9 +316,6 @@ 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"(?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 98036cb16..a6417580e 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({"id": "hello"}, ["Hello World"]) + return idom.html.p(["Hello World"], id="hello") await display.show(Hello) @@ -56,11 +56,9 @@ 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) @@ -85,7 +83,7 @@ async def test_use_connection(display: DisplayFixture): @idom.component def ShowScope(): conn.current = idom.use_connection() - return html.pre({"id": "scope"}, str(conn.current)) + return html.pre(str(conn.current), id="scope") await display.show(ShowScope) @@ -99,7 +97,7 @@ async def test_use_scope(display: DisplayFixture): @idom.component def ShowScope(): scope.current = idom.use_scope() - return html.pre({"id": "scope"}, str(scope.current)) + return html.pre(str(scope.current), id="scope") await display.show(ShowScope) @@ -147,7 +145,7 @@ async def test_use_request(display: DisplayFixture, hook_name): @idom.component def ShowRoute(): hook_val.current = hook() - return html.pre({"id": "hook"}, str(hook_val.current)) + return html.pre(str(hook_val.current), id="hook") await display.show(ShowRoute) diff --git a/tests/test_client.py b/tests/test_client.py index 0e48e3390..e40a6df69 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -22,7 +22,7 @@ async def test_automatic_reconnect(browser: Browser): @idom.component def OldComponent(): - return idom.html.p({"id": "old-component"}, "old") + return idom.html.p("old", id="old-component") async with AsyncExitStack() as exit_stack: server = await exit_stack.enter_async_context(BackendFixture(port=port)) @@ -43,7 +43,7 @@ def OldComponent(): @idom.component def NewComponent(): state, set_state.current = idom.hooks.use_state(0) - return idom.html.p({"id": f"new-component-{state}"}, f"new-{state}") + return idom.html.p(f"new-{state}", id=f"new-component-{state}") async with AsyncExitStack() as exit_stack: server = await exit_stack.enter_async_context(BackendFixture(port=port)) @@ -76,12 +76,10 @@ 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={"backgroundColor": color, "color": "white"}, ) await display.show(ButtonWithChangingColor) @@ -117,7 +115,7 @@ async def handle_change(event): await asyncio.sleep(delay) set_value(event["target"]["value"]) - return idom.html.input({"onChange": handle_change, "id": "test-input"}) + return idom.html.input(on_change=handle_change, id="test-input") await display.show(SomeComponent) diff --git a/tests/test_core/test_component.py b/tests/test_core/test_component.py index 28c8b00f2..9c2513a03 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(kwargs, args) + return idom.html.div(*args, **kwargs) - 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"], } @@ -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({"id": "hello"}, ["Hello World"]) + return idom.html.p(["Hello World"], id="hello") 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 89f1dfa4c..3eee82e6c 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({"onKeyDown": on_key_down, "id": "input"}) + return idom.html.input(on_key_down=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({"onClick": on_click, "id": "click"}, ["Click Me!"]) + return idom.html.button(["Click Me!"], on_click=on_click, id="click") else: - return idom.html.p({"id": "complete"}, ["Complete"]) + return idom.html.p(["Complete"], id="complete") await display.show(Button) @@ -194,26 +194,14 @@ 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", - }, - "onClick": inner_click_no_op, - "id": "inner", - }, + style={"height": "30px", "width": "30px", "backgroundColor": "blue"}, + on_click=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 293e773a2..9654eaddb 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -181,18 +181,14 @@ 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'}", ) @@ -237,9 +233,9 @@ async def on_change(event): set_message(event["target"]["value"]) if message is None: - return idom.html.input({"id": "input", "onChange": on_change}) + return idom.html.input(id="input", on_change=on_change) else: - return idom.html.p({"id": "complete"}, ["Complete"]) + return idom.html.p(["Complete"], id="complete") await display.show(Input) @@ -261,13 +257,9 @@ def double_set_state(event): set_state_2(state_2 + 1) return idom.html.div( - 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"), + 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), ) await display.show(SomeComponent) diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index 805ff5653..f69367586 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -507,10 +507,8 @@ def bad_trigger(): raise ValueError("Called bad trigger") children = [ - idom.html.button( - {"onClick": good_trigger, "id": "good"}, "good", key="good" - ), - idom.html.button({"onClick": bad_trigger, "id": "bad"}, "bad", key="bad"), + idom.html.button("good", key="good", on_click=good_trigger, id="good"), + idom.html.button("bad", key="bad", on_click=bad_trigger, id="bad"), ] if reverse_children: @@ -567,7 +565,7 @@ def callback(): def callback(): raise ValueError("Called bad trigger") - return idom.html.button({"onClick": callback, "id": "good"}, "good") + return idom.html.button("good", on_click=callback, id="good") async with idom.Layout(RootComponent()) as layout: await layout.render() @@ -649,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({"onClick": set_value}, "state is: ", value) - event_handler.current = weakref(button["eventHandlers"]["onClick"].function) + button = idom.html.button("state is: ", value, on_click=set_value) + event_handler.current = weakref(button["eventHandlers"]["on_click"].function) return button async with idom.Layout(HasEventHandlerAtRoot()) as layout: @@ -671,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({"onClick": set_value}, "state is: ", value) - event_handler.current = weakref(button["eventHandlers"]["onClick"].function) + button = idom.html.button("state is: ", value, on_click=set_value) + event_handler.current = weakref(button["eventHandlers"]["on_click"].function) return idom.html.div(idom.html.div(button)) async with idom.Layout(HasNestedEventHandler()) as layout: @@ -753,7 +751,7 @@ def ComponentWithBadEventHandler(): def raise_error(): raise Exception("bad event handler") - return idom.html.button({"onClick": raise_error}) + return idom.html.button(on_click=raise_error) with assert_idom_did_log(match_error="bad event handler"): @@ -850,7 +848,7 @@ def SomeComponent(): return idom.html.div( [ idom.html.div( - idom.html.input({"onChange": lambda event: None}), + idom.html.input(on_change=lambda event: None), key=str(i), ) for i in items @@ -909,14 +907,14 @@ def Root(): toggle, toggle_type.current = use_toggle(True) handler = element_static_handler.use(lambda: None) if toggle: - return html.div(html.button({"onEvent": 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({"onAnotherEvent": handler}) + return html.button(on_another_event=handler) async with idom.Layout(Root()) as layout: await layout.render() @@ -999,8 +997,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))}, - "click me", + "click me", on_click=set_child_key_num.use(lambda: set_state(state + 1)) ), Child("some-key"), Child(f"key-{state}"), @@ -1073,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 20eb9fa86..94b403ebc 100644 --- a/tests/test_core/test_serve.py +++ b/tests/test_core/test_serve.py @@ -11,7 +11,7 @@ from tests.tooling.common import event_message -EVENT_NAME = "onEvent" +EVENT_NAME = "on_event" STATIC_EVENT_HANDLER = StaticEventHandler() @@ -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({"onClick": block_forever}), - idom.html.button({"onClick": handle_event}), + idom.html.button(on_click=block_forever), + idom.html.button(on_click=handle_event), ) send_queue = asyncio.Queue() diff --git a/tests/test_core/test_vdom.py b/tests/test_core/test_vdom.py index aeca2b4ea..7072d3f3a 100644 --- a/tests/test_core/test_vdom.py +++ b/tests/test_core/test_vdom.py @@ -7,7 +7,12 @@ 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 +from idom.core.vdom import ( + is_vdom, + make_vdom_constructor, + validate_vdom_json, + with_import_source, +) FAKE_EVENT_HANDLER = EventHandler(lambda data: None) @@ -36,7 +41,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"}}}, ), ( @@ -48,11 +53,7 @@ def test_is_vdom(result, value): }, ), ( - idom.vdom("div", event_handlers=FAKE_EVENT_HANDLER_DICT), - {"tagName": "div", "eventHandlers": FAKE_EVENT_HANDLER_DICT}, - ), - ( - idom.vdom("div", {"onEvent": FAKE_EVENT_HANDLER}), + idom.vdom("div", on_event=FAKE_EVENT_HANDLER), {"tagName": "div", "eventHandlers": FAKE_EVENT_HANDLER_DICT}, ), ( @@ -78,9 +79,9 @@ def test_is_vdom(result, value): {"tagName": "div", "children": [1, 4, 9]}, ), ( - idom.vdom( - "MyComponent", - import_source={"source": "./some-script.js", "fallback": "loading..."}, + with_import_source( + idom.vdom("MyComponent"), + {"source": "./some-script.js", "fallback": "loading..."}, ), { "tagName": "MyComponent", @@ -99,44 +100,23 @@ def test_simple_node_construction(actual, expected): async def test_callable_attributes_are_cast_to_event_handlers(): params_from_calls = [] - node = idom.vdom("div", {"onEvent": 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"} - handler = event_handlers["onEvent"] - assert event_handlers == {"onEvent": EventHandler(handler.function)} + handler = event_handlers["on_event"] + assert event_handlers == {"on_event": EventHandler(handler.function)} await handler.function([1, 2]) await handler.function([3, 4, 5]) assert params_from_calls == [(1, 2), (3, 4, 5)] -async def test_event_handlers_and_callable_attributes_are_automatically_merged(): - calls = [] - - node = idom.vdom( - "div", - {"onEvent": lambda: calls.append("callable_attr")}, - event_handlers={ - "onEvent": EventHandler(lambda data: calls.append("normal_event_handler")) - }, - ) - - event_handlers = node.pop("eventHandlers") - assert node == {"tagName": "div"} - - handler = event_handlers["onEvent"] - assert event_handlers == {"onEvent": EventHandler(handler.function)} - - await handler.function([]) - assert calls == ["normal_event_handler", "callable_attr"] - - def test_make_vdom_constructor(): elmt = make_vdom_constructor("some-tag") - assert elmt({"data": 1}, [elmt()]) == { + assert elmt(elmt(), data=1) == { "tagName": "some-tag", "children": [{"tagName": "some-tag"}], "attributes": {"data": 1}, diff --git a/tests/test_html.py b/tests/test_html.py index 794a16474..b296447a3 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,11 +101,9 @@ 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)}" ), ) @@ -151,5 +149,5 @@ def test_simple_fragment(): def test_fragment_can_have_no_attributes(): - with pytest.raises(TypeError, match="Fragments cannot have attributes"): - html._({"some-attribute": 1}) + with pytest.raises(TypeError): + html._(some_attribute=1) diff --git a/tests/test_testing.py b/tests/test_testing.py index 27afa980a..9b4941d4c 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({"id": f"hotswap-{count.current}"}, count.current) + return html.div(count.current, id=f"hotswap-{count.current}") return constructor @@ -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("incr", on_click=on_click, id="incr-button") 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 f7518b014..08397b77c 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -205,17 +205,15 @@ def test_del_html_body_transform(): f"
{html_escape(str(SOME_OBJECT))}
", ), ( - html.div({"someAttribute": SOME_OBJECT}), + html.div(some_attribute=SOME_OBJECT), f'
', ), ( - html.div( - "hello", html.a({"href": "https://example.com"}, "example"), "world" - ), + html.div("hello", html.a("example", href="https://example.com"), "world"), '
helloexampleworld
', ), ( - html.button({"onClick": lambda event: None}), + html.button(on_click=lambda event: None), "", ), ( @@ -227,17 +225,17 @@ def test_del_html_body_transform(): "
hello
world", ), ( - 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.div(style="background-color:blue;margin-left:10px"), '
', ), ( html._( html.div("hello"), - html.a({"href": "https://example.com"}, "example"), + html.a("example", href="https://example.com"), ), '
hello
example', ), @@ -245,16 +243,14 @@ def test_del_html_body_transform(): html.div( html._( html.div("hello"), - html.a({"href": "https://example.com"}, "example"), + html.a("example", href="https://example.com"), ), html.button(), ), '
hello
example
', ), ( - html.div( - {"dataSomething": 1, "dataSomethingElse": 2, "dataisnotdashed": 3} - ), + html.div(data_something=1, data_something_else=2, dataisnotdashed=3), '
', ), ], diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 497b89787..46cef3a67 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -41,7 +41,7 @@ def ShowCurrentComponent(): await display.page.wait_for_selector("#some-component", state="attached") set_current_component.current( - idom.html.h1({"id": "some-other-component"}, "some other component") + idom.html.h1("some other component", id="some-other-component") ) # the new component has been displayed From 93a56b260675ce7ee8a1ec58edc326dabd9aab17 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 25 Jan 2023 23:45:41 -0800 Subject: [PATCH 07/24] more fixes --- .../_examples/moving_dot.py | 6 +- .../_examples/moving_dot_broken.py | 6 +- .../_examples/set_remove.py | 2 +- .../_examples/set_update.py | 2 +- .../_examples/set_color_3_times.py | 4 +- .../_examples/stop_event_propagation.py | 4 +- .../reference/_examples/matplotlib_plot.py | 2 +- .../reference/_examples/simple_dashboard.py | 2 +- docs/source/reference/_examples/snake_game.py | 10 +-- .../idom-client-react/src/element-utils.js | 38 +++++++--- src/idom/_option.py | 24 +------ src/idom/_warnings.py | 31 ++++++++ src/idom/core/vdom.py | 72 +++++++++++++++---- src/idom/utils.py | 12 +--- src/idom/web/module.py | 11 ++- src/idom/widgets.py | 2 +- tests/test_client.py | 2 +- tests/test_core/test_vdom.py | 2 +- tests/test_utils.py | 14 +++- tests/test_web/test_module.py | 17 ++--- 20 files changed, 171 insertions(+), 92 deletions(-) create mode 100644 src/idom/_warnings.py 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 02a787b33..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 @@ -19,8 +19,8 @@ async def handle_pointer_move(event): html.div( style={ "position": "absolute", - "backgroundColor": "red", - "borderRadius": "50%", + "background_color": "red", + "border_radius": "50%", "width": "20px", "height": "20px", "left": "-10px", @@ -33,7 +33,7 @@ async def handle_pointer_move(event): "position": "relative", "height": "200px", "width": "100%", - "backgroundColor": "white", + "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 ce9759382..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 @@ -17,8 +17,8 @@ def handle_pointer_move(event): html.div( style={ "position": "absolute", - "backgroundColor": "red", - "borderRadius": "50%", + "background_color": "red", + "border_radius": "50%", "width": "20px", "height": "20px", "left": "-10px", @@ -31,7 +31,7 @@ def handle_pointer_move(event): "position": "relative", "height": "200px", "width": "100%", - "backgroundColor": "white", + "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 98a268d44..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 @@ -23,7 +23,7 @@ def handle_click(event): style={ "height": "30px", "width": "30px", - "backgroundColor": "black" + "background_color": "black" if index in selected_indices else "white", "outline": "1px solid grey", 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 d34e74cb5..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 @@ -20,7 +20,7 @@ def handle_click(event): style={ "height": "30px", "width": "30px", - "backgroundColor": "black" + "background_color": "black" if index in selected_indices else "white", "outline": "1px solid grey", 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 c04c75c86..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,9 +15,9 @@ def handle_reset(event): return html.div( html.button( - "Set Color", on_click=handle_click, style={"backgroundColor": color} + "Set Color", on_click=handle_click, style={"background_color": color} ), - html.button("Reset", on_click=handle_reset, style={"backgroundColor": color}), + html.button("Reset", on_click=handle_reset, style={"background_color": color}), ) 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 e4ff7aad5..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 @@ -13,10 +13,10 @@ def DivInDiv(): lambda event: set_inner_count(inner_count + 1), stop_propagation=stop_propagatation, ), - style={"height": "50px", "width": "50px", "backgroundColor": "blue"}, + style={"height": "50px", "width": "50px", "background_color": "blue"}, ), on_click=lambda event: set_outer_count(outer_count + 1), - style={"height": "100px", "width": "100px", "backgroundColor": "red"}, + style={"height": "100px", "width": "100px", "background_color": "red"}, ) return html.div( diff --git a/docs/source/reference/_examples/matplotlib_plot.py b/docs/source/reference/_examples/matplotlib_plot.py index 38f8624e1..e219e4e96 100644 --- a/docs/source/reference/_examples/matplotlib_plot.py +++ b/docs/source/reference/_examples/matplotlib_plot.py @@ -61,7 +61,7 @@ def poly_coef_input(index, 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"}, + style={"margin_top": "5px"}, ) diff --git a/docs/source/reference/_examples/simple_dashboard.py b/docs/source/reference/_examples/simple_dashboard.py index a22648522..504ff6d1a 100644 --- a/docs/source/reference/_examples/simple_dashboard.py +++ b/docs/source/reference/_examples/simple_dashboard.py @@ -83,7 +83,7 @@ def update_value(value): set_value_callback(value) return idom.html.fieldset( - idom.html.legend(label, style={"font-size": "medium"}), + 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_="number-input-container", diff --git a/docs/source/reference/_examples/snake_game.py b/docs/source/reference/_examples/snake_game.py index e7d6f7234..cf0328ef4 100644 --- a/docs/source/reference/_examples/snake_game.py +++ b/docs/source/reference/_examples/snake_game.py @@ -154,9 +154,9 @@ def create_grid(grid_size, block_scale): "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)", + "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, ) @@ -168,7 +168,7 @@ def create_grid_block(color, block_scale, key): style={ "height": f"{block_scale}px", "width": f"{block_scale}px", - "backgroundColor": color, + "background_color": color, "outline": "1px solid grey", }, ) @@ -177,7 +177,7 @@ def create_grid_block(color, block_scale, key): 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/src/client/packages/idom-client-react/src/element-utils.js b/src/client/packages/idom-client-react/src/element-utils.js index afeb7a62e..d1f966d73 100644 --- a/src/client/packages/idom-client-react/src/element-utils.js +++ b/src/client/packages/idom-client-react/src/element-utils.js @@ -26,9 +26,11 @@ export function createElementAttributes(model, sendEvent) { } } - return Object.fromEntries( - Object.entries(attributes).map(([key, value]) => [snakeToCamel(key), value]) + const attrs = Object.fromEntries( + Object.entries(attributes).map(normalizeAttribute) ); + console.log(attrs); + return attrs; } function createEventHandler(sendEvent, eventSpec) { @@ -53,12 +55,32 @@ function createEventHandler(sendEvent, eventSpec) { }; } -function snakeToCamel(str) { - if (str.startsWith("data_") || str.startsWith("aria_")) { - return str.replace("_", "-"); +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 { - return str - .toLowerCase() - .replace(/([-_][a-z])/g, (group) => group.toUpperCase().replace("_", "")); + 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/idom/_option.py b/src/idom/_option.py index d9d8e9d79..2c04c6761 100644 --- a/src/idom/_option.py +++ b/src/idom/_option.py @@ -1,12 +1,12 @@ 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 idom._warnings import warn + _O = TypeVar("_O") logger = getLogger(__name__) @@ -129,25 +129,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..261e63bf5 --- /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) + + +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/core/vdom.py b/src/idom/core/vdom.py index 40340367b..c4373b784 100644 --- a/src/idom/core/vdom.py +++ b/src/idom/core/vdom.py @@ -2,10 +2,10 @@ import logging from typing import Any, Mapping, cast -from warnings import warn from fastjsonschema import compile as compile_json_schema +from idom._warnings import warn from idom.config import IDOM_DEBUG_MODE from idom.core.events import ( EventHandler, @@ -15,6 +15,7 @@ from idom.core.types import ( ComponentType, EventHandlerDict, + EventHandlerMapping, EventHandlerType, ImportSourceDict, Key, @@ -159,11 +160,13 @@ def vdom( if isinstance(child, dict) and "tagName" not in child: warn( ( - "Element constructor signatures have changed! 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'). For " - "start a discussion if you need help transitioning to this new " - "interface: https://github.com/idom-team/idom/discussions/new?category=question" + "Element constructor signatures have changed! This will be an " + "error in a future release. 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, ) @@ -194,19 +197,62 @@ def with_import_source(element: VdomDict, import_source: ImportSourceDict) -> Vd return {**element, "importSource": import_source} -def make_vdom_constructor(tag: str, allow_children: bool = True) -> VdomDictConstructor: +def with_event_handlers( + element: VdomDict, event_handlers: EventHandlerMapping +) -> VdomDict: + if "eventHandlers" in element: + old_handlers = element["eventHandlers"] + new_handlers = { + merge_event_handlers((old_handlers[k], event_handlers[h])) + if k in old_handlers + else h + for k, h in event_handlers + } + return {**element, "eventHandlers": new_handlers} + else: + return {**element, "eventHandlers": dict(event_handlers)} + + +def make_vdom_constructor( + tag: str, + allow_children: bool = True, + import_source: ImportSourceDict | None = None, + event_handlers: EventHandlerMapping | 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 first ``tag`` argument. """ - def constructor( - *children: VdomChild, key: Key | None = None, **attributes: Any - ) -> VdomDict: - if not allow_children and children: - raise TypeError(f"{tag!r} nodes cannot have children.") - return vdom(tag, *children, key=key, **attributes) + if import_source is not None: + + def constructor( + *children: VdomChild, key: Key | None = None, **attributes: Any + ) -> VdomDict: + if not allow_children and children: + raise TypeError(f"{tag!r} nodes cannot have children.") + return with_import_source( + vdom(tag, *children, key=key, **attributes), import_source + ) + + else: + + def constructor( + *children: VdomChild, key: Key | None = None, **attributes: Any + ) -> VdomDict: + if not allow_children and children: + raise TypeError(f"{tag!r} nodes cannot have children.") + return vdom(tag, *children, key=key, **attributes) + + if event_handlers: + _constructor = constructor + + def constructor( + *children: VdomChild, key: Key | None = None, **attributes: Any + ) -> VdomDict: + model = _constructor(*children, key=key, **attributes) + return with_event_handlers(model, event_handlers) # replicate common function attributes constructor.__name__ = tag diff --git a/src/idom/utils.py b/src/idom/utils.py index ea4161192..64bfc8a33 100644 --- a/src/idom/utils.py +++ b/src/idom/utils.py @@ -154,7 +154,7 @@ def _etree_to_vdom( vdom: VdomDict if hasattr(idom.html, node.tag): - vdom = getattr(idom.html, node.tag)(attributes, *children, key=key) + vdom = getattr(idom.html, node.tag)(*children, key=key, **attributes) else: vdom = {"tagName": node.tag} if children: @@ -233,9 +233,9 @@ 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 camelCase keys + # Convert style attribute from str -> dict with snake case keys vdom["attributes"]["style"] = { - _hypen_to_camel_case(key.strip()): value.strip() + key.strip().replace("-", "_"): value.strip() for key, value in ( part.split(":", 1) for part in vdom["attributes"]["style"].split(";") @@ -266,12 +266,6 @@ def _generate_vdom_children( ) -def _hypen_to_camel_case(string: str) -> str: - """Convert a hypenated string to camelCase.""" - first, _, remainder = string.partition("-") - return first.lower() + remainder.title().replace("-", "") - - def del_html_head_body_transform(vdom: VdomDict) -> VdomDict: """Transform intended for use with `html_to_vdom`. diff --git a/src/idom/web/module.py b/src/idom/web/module.py index 1c6eba98c..99d1058f6 100644 --- a/src/idom/web/module.py +++ b/src/idom/web/module.py @@ -4,13 +4,12 @@ import logging import shutil from dataclasses import dataclass -from functools import partial from pathlib import Path from string import Template from typing import Any, List, NewType, Optional, Set, Tuple, Union, overload from urllib.parse import urlparse -from warnings import warn +from idom._warnings import warn from idom.config import IDOM_DEBUG_MODE, IDOM_WEB_MODULES_DIR from idom.core.types import ImportSourceDict, VdomDictConstructor from idom.core.vdom import make_vdom_constructor @@ -373,11 +372,9 @@ def _make_export( fallback: Optional[Any], allow_children: bool, ) -> VdomDictConstructor: - return partial( - make_vdom_constructor( - name, - allow_children=allow_children, - ), + return make_vdom_constructor( + name, + allow_children=allow_children, import_source=ImportSourceDict( source=web_module.source, sourceType=web_module.source_type, diff --git a/src/idom/widgets.py b/src/idom/widgets.py index 6e114aff2..3090e3113 100644 --- a/src/idom/widgets.py +++ b/src/idom/widgets.py @@ -2,13 +2,13 @@ from base64 import b64encode from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, TypeVar, Union -from warnings import warn from typing_extensions import Protocol import idom from . import html +from ._warnings import warn from .core.types import ComponentConstructor, VdomDict from .testing.backend import _hotswap, _MountFunc diff --git a/tests/test_client.py b/tests/test_client.py index e40a6df69..c6646f302 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -79,7 +79,7 @@ def ButtonWithChangingColor(): f"color: {color}", id="my-button", on_click=lambda event: set_color_toggle(not color_toggle), - style={"backgroundColor": color, "color": "white"}, + style={"background_color": color, "color": "white"}, ) await display.show(ButtonWithChangingColor) diff --git a/tests/test_core/test_vdom.py b/tests/test_core/test_vdom.py index 7072d3f3a..f9f99e5a8 100644 --- a/tests/test_core/test_vdom.py +++ b/tests/test_core/test_vdom.py @@ -16,7 +16,7 @@ FAKE_EVENT_HANDLER = EventHandler(lambda data: None) -FAKE_EVENT_HANDLER_DICT = {"onEvent": FAKE_EVENT_HANDLER} +FAKE_EVENT_HANDLER_DICT = {"on_event": FAKE_EVENT_HANDLER} @pytest.mark.parametrize( diff --git a/tests/test_utils.py b/tests/test_utils.py index 08397b77c..3fc5ef2f0 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -46,12 +46,20 @@ def test_ref_repr(): @pytest.mark.parametrize( "case", [ - {"source": "
", "model": {"tagName": "div"}}, + { + "source": "
", + "model": {"tagName": "div"}, + }, + { + "source": "
", + # we don't touch attribute values + "model": {"tagName": "div", "attributes": {"some-attribute": "thing"}}, + }, { "source": "
", "model": { "tagName": "div", - "attributes": {"style": {"backgroundColor": "blue"}}, + "attributes": {"style": {"background_color": "blue"}}, }, }, { @@ -137,7 +145,7 @@ def test_html_to_vdom_with_style_attr(): source = '

Hello World.

' expected = { - "attributes": {"style": {"backgroundColor": "green", "color": "red"}}, + "attributes": {"style": {"background_color": "green", "color": "red"}}, "children": ["Hello World."], "tagName": "p", } diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 46cef3a67..1131ea87c 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 @@ -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,7 +105,8 @@ async def test_module_from_file(display: DisplayFixture): @idom.component def ShowSimpleButton(): return SimpleButton( - {"id": "my-button", "onClick": lambda event: is_clicked.set_current(True)} + id="my-button", + on_click=lambda event: is_clicked.set_current(True), ) await display.show(ShowSimpleButton) @@ -198,11 +199,11 @@ async def test_module_exports_multiple_components(display: DisplayFixture): ["Header1", "Header2"], ) - await display.show(lambda: Header1({"id": "my-h1"}, "My Header 1")) + await display.show(lambda: Header1("My Header 1", id="my-h1")) await display.page.wait_for_selector("#my-h1", state="attached") - await display.show(lambda: Header2({"id": "my-h2"}, "My Header 2")) + await display.show(lambda: Header2("My Header 2", id="my-h2")) await display.page.wait_for_selector("#my-h2", state="attached") @@ -215,9 +216,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), ) ) From 8c6b2381f175144446a3eb6e29aa79bf35a94037 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 25 Jan 2023 23:50:28 -0800 Subject: [PATCH 08/24] correct more tests --- src/idom/widgets.py | 14 +++++++------- tests/test_widgets.py | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/idom/widgets.py b/src/idom/widgets.py index 3090e3113..a8899b757 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 Any, Callable, Dict, List, Optional, Sequence, Tuple, TypeVar, Union +from typing import Any, Callable, Iterable, Tuple, TypeVar, Union from typing_extensions import Protocol @@ -16,7 +16,7 @@ def image( format: str, value: Union[str, bytes] = "", - attributes: Optional[Dict[str, Any]] = None, + **attributes: Any, ) -> VdomDict: """Utility for constructing an image from a string or bytes @@ -33,19 +33,19 @@ def image( base64_value = b64encode(bytes_value).decode() src = f"data:image/{format};base64,{base64_value}" - return {"tagName": "img", "attributes": {"src": src, **(attributes or {})}} + return {"tagName": "img", "attributes": {"src": src, **attributes}} _Value = TypeVar("_Value") def use_linked_inputs( - attributes: Sequence[Dict[str, Any]], + attributes: Iterable[dict[str, Any]], on_change: Callable[[_Value], None] = lambda value: None, cast: _CastFunc[_Value] = lambda value: value, initial_value: str = "", ignore_empty: bool = True, -) -> List[VdomDict]: +) -> list[VdomDict]: """Return a list of linked inputs equal to the number of given attributes. Parameters: @@ -67,7 +67,7 @@ def use_linked_inputs( """ value, set_value = idom.hooks.use_state(initial_value) - def sync_inputs(event: Dict[str, Any]) -> None: + def sync_inputs(event: dict[str, Any]) -> None: new_value = event["target"]["value"] set_value(new_value) if not new_value and ignore_empty: @@ -82,7 +82,7 @@ def sync_inputs(event: Dict[str, Any]) -> None: key = attrs.pop("key", None) attrs.update({"onChange": sync_inputs, "value": value}) - inputs.append(html.input(attrs, key=key)) + inputs.append(html.input(key=key, **attrs)) return inputs diff --git a/tests/test_widgets.py b/tests/test_widgets.py index cd6f9b2c2..dd5aa21ab 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 7d7e3c2e779f0c9e33a0aeb13b538ccb9746af75 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 25 Jan 2023 23:56:41 -0800 Subject: [PATCH 09/24] improve warning --- src/idom/core/vdom.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/idom/core/vdom.py b/src/idom/core/vdom.py index c4373b784..600e48cf2 100644 --- a/src/idom/core/vdom.py +++ b/src/idom/core/vdom.py @@ -160,12 +160,18 @@ def vdom( if isinstance(child, dict) and "tagName" not in child: warn( ( - "Element constructor signatures have changed! This will be an " - "error in a future release. 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: " + "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, From e4f30aea4769dd4c5b0533ad27cbde736776cd35 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 26 Jan 2023 00:18:11 -0800 Subject: [PATCH 10/24] improve coverage --- pyproject.toml | 3 ++ src/idom/core/vdom.py | 66 +++++++++---------------------------------- 2 files changed, 17 insertions(+), 52 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cc0199bcd..c7a429fd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,9 @@ exclude_lines = [ '\.\.\.', "raise NotImplementedError", ] +omit = [ + "src/idom/__main__.py", +] [tool.pydocstyle] inherit = false diff --git a/src/idom/core/vdom.py b/src/idom/core/vdom.py index 600e48cf2..f084f54b8 100644 --- a/src/idom/core/vdom.py +++ b/src/idom/core/vdom.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from typing import Any, Mapping, cast +from typing import Any, DefaultDict, Mapping, cast from fastjsonschema import compile as compile_json_schema @@ -15,7 +15,6 @@ from idom.core.types import ( ComponentType, EventHandlerDict, - EventHandlerMapping, EventHandlerType, ImportSourceDict, Key, @@ -157,7 +156,7 @@ def vdom( flattened_children: list[VdomChild] = [] for child in children: - if isinstance(child, dict) and "tagName" not in child: + if isinstance(child, dict) and "tagName" not in child: # pragma: no cover warn( ( "Element constructor signatures have changed! This will be an error " @@ -203,27 +202,10 @@ def with_import_source(element: VdomDict, import_source: ImportSourceDict) -> Vd return {**element, "importSource": import_source} -def with_event_handlers( - element: VdomDict, event_handlers: EventHandlerMapping -) -> VdomDict: - if "eventHandlers" in element: - old_handlers = element["eventHandlers"] - new_handlers = { - merge_event_handlers((old_handlers[k], event_handlers[h])) - if k in old_handlers - else h - for k, h in event_handlers - } - return {**element, "eventHandlers": new_handlers} - else: - return {**element, "eventHandlers": dict(event_handlers)} - - def make_vdom_constructor( tag: str, allow_children: bool = True, import_source: ImportSourceDict | None = None, - event_handlers: EventHandlerMapping | None = None, ) -> VdomDictConstructor: """Return a constructor for VDOM dictionaries with the given tag name. @@ -231,34 +213,17 @@ def make_vdom_constructor( first ``tag`` argument. """ - if import_source is not None: + def constructor( + *children: VdomChild, key: Key | None = None, **attributes: Any + ) -> VdomDict: + if not allow_children and children: + raise TypeError(f"{tag!r} nodes cannot have children.") - def constructor( - *children: VdomChild, key: Key | None = None, **attributes: Any - ) -> VdomDict: - if not allow_children and children: - raise TypeError(f"{tag!r} nodes cannot have children.") - return with_import_source( - vdom(tag, *children, key=key, **attributes), import_source - ) + model = vdom(tag, *children, key=key, **attributes), import_source + if import_source is not None: + model = with_import_source(model, import_source) - else: - - def constructor( - *children: VdomChild, key: Key | None = None, **attributes: Any - ) -> VdomDict: - if not allow_children and children: - raise TypeError(f"{tag!r} nodes cannot have children.") - return vdom(tag, *children, key=key, **attributes) - - if event_handlers: - _constructor = constructor - - def constructor( - *children: VdomChild, key: Key | None = None, **attributes: Any - ) -> VdomDict: - model = _constructor(*children, key=key, **attributes) - return with_event_handlers(model, event_handlers) + return model # replicate common function attributes constructor.__name__ = tag @@ -280,7 +245,7 @@ def separate_attributes_and_event_handlers( attributes: Mapping[str, Any] ) -> tuple[dict[str, Any], EventHandlerDict]: separated_attributes = {} - separated_event_handlers: dict[str, list[EventHandlerType]] = {} + separated_handlers: DefaultDict[str, list[EventHandlerType]] = DefaultDict(list) for k, v in attributes.items(): @@ -299,13 +264,10 @@ 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 From 8fed0c09f227df7754056210459c2a139c325537 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 26 Jan 2023 10:28:13 -0800 Subject: [PATCH 11/24] fix typo --- 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 f084f54b8..75407e2aa 100644 --- a/src/idom/core/vdom.py +++ b/src/idom/core/vdom.py @@ -219,7 +219,7 @@ def constructor( if not allow_children and children: raise TypeError(f"{tag!r} nodes cannot have children.") - model = vdom(tag, *children, key=key, **attributes), import_source + model = vdom(tag, *children, key=key, **attributes) if import_source is not None: model = with_import_source(model, import_source) From 04beb2f2ef7fb5dca6d79fd0aee6bc6d35bc2ce1 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 26 Jan 2023 10:28:46 -0800 Subject: [PATCH 12/24] remove console logs --- src/client/packages/idom-client-react/src/element-utils.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 d1f966d73..334ca9019 100644 --- a/src/client/packages/idom-client-react/src/element-utils.js +++ b/src/client/packages/idom-client-react/src/element-utils.js @@ -26,11 +26,7 @@ export function createElementAttributes(model, sendEvent) { } } - const attrs = Object.fromEntries( - Object.entries(attributes).map(normalizeAttribute) - ); - console.log(attrs); - return attrs; + return Object.fromEntries(Object.entries(attributes).map(normalizeAttribute)); } function createEventHandler(sendEvent, eventSpec) { From 9bf75f4887e3f4fd8ae16f7165a17f46bd2d32f2 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 26 Jan 2023 10:29:11 -0800 Subject: [PATCH 13/24] Python<3.10 compat --- src/idom/_console/update_html_usages.py | 125 ++++++++++++------------ 1 file changed, 62 insertions(+), 63 deletions(-) diff --git a/src/idom/_console/update_html_usages.py b/src/idom/_console/update_html_usages.py index 2e1219099..66906b075 100644 --- a/src/idom/_console/update_html_usages.py +++ b/src/idom/_console/update_html_usages.py @@ -3,7 +3,7 @@ import ast import re from collections.abc import Sequence -from glob import glob +from dataclasses import dataclass from keyword import kwlist from pathlib import Path from textwrap import indent @@ -12,6 +12,7 @@ from typing import Iterator import click +from typing_extensions import TypeGuard from idom import html @@ -64,73 +65,32 @@ def generate_rewrite(file: Path, source: str) -> None: 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 isinstance(func, ast.Attribute): + name = func.attr + elif isinstance(func, ast.Name): + name = func.id + else: + name = "" if hasattr(html, name) or name == "vdom": if name == "vdom": - # first arg is the tag name - node_args_pre = node.args[:1] - node.args = node.args[1:] + maybe_attr_dict_node = node.args[1] + # remove attr dict from new args + new_args = node.args[:1] + node.args[2:] else: - node_args_pre = [] - - match node.args: - case [ast.Dict(keys, values), *_]: - new_kwargs = list(node.keywords) - should_change = True - for k, v in zip(keys, values): - if isinstance(k, ast.Constant) and isinstance(k.value, str): - if k.value == "tagName": - # this is a vdom dict declaration - should_change = False - break - new_kwargs.append( - ast.keyword(arg=conv_attr_name(k.value), value=v) - ) - else: - new_kwargs = [ast.keyword(arg=None, value=node.args[0])] - should_change = True - break - if should_change: - node.args = node_args_pre + 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 == "tagName": - # this is a vdom dict declaration - break - 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) + maybe_attr_dict_node = node.args[0] + # remove attr dict from new args + new_args = node.args[1:] + + if node.args: + 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.args = node_args_pre + node.args[1:] - node.keywords = new_kwargs - changed.append((node, *parents)) + node.keywords.extend(new_keyword_info.keywords) - case _: - pass + node.args = new_args + changed.append((node, *parents)) if not changed: return @@ -202,6 +162,39 @@ def generate_rewrite(file: Path, source: 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) + + def find_comments(lines: list[str]) -> list[str]: iter_lines = iter(lines) return [ @@ -223,3 +216,9 @@ def walk_with_parent( 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: Sequence[ast.keyword] From 05778f91be05f5da72b7964007372efa594969f3 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 26 Jan 2023 11:13:26 -0800 Subject: [PATCH 14/24] more cov --- src/idom/_console/update_html_usages.py | 22 +++++++---------- .../test__console/test_update_html_usages.py | 24 +++++++++++++++++-- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/idom/_console/update_html_usages.py b/src/idom/_console/update_html_usages.py index 66906b075..b9fc8ff6d 100644 --- a/src/idom/_console/update_html_usages.py +++ b/src/idom/_console/update_html_usages.py @@ -63,14 +63,14 @@ def generate_rewrite(file: Path, source: str) -> None: changed: list[Sequence[ast.AST]] = [] for parents, node in walk_with_parent(tree): - if isinstance(node, ast.Call): + if isinstance(node, ast.Call) and node.args: func = node.func if isinstance(func, ast.Attribute): name = func.attr elif isinstance(func, ast.Name): name = func.id else: - name = "" + continue if hasattr(html, name) or name == "vdom": if name == "vdom": maybe_attr_dict_node = node.args[1] @@ -112,7 +112,7 @@ def generate_rewrite(file: Path, source: str) -> None: ): nodes_to_unparse.append(current_node) break - else: + else: # pragma: no cover raise RuntimeError("Failed to change code") # check if an nodes to rewrite contain eachother, pick outermost nodes @@ -134,12 +134,11 @@ def generate_rewrite(file: Path, source: str) -> None: # 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 = "" + 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 @@ -148,10 +147,7 @@ def generate_rewrite(file: Path, source: str) -> None: " " * (node.col_offset - len(before_replacement)), ) - if node.end_lineno: - lines[node.lineno - 1 : node.end_lineno] = [replacement] - else: - lines[node.lineno - 1] = replacement + lines[node.lineno - 1 : node.end_lineno or node.lineno] = [replacement] if comments: moved_comment_lines_from_end.append(len(lines) - node.lineno) diff --git a/tests/test__console/test_update_html_usages.py b/tests/test__console/test_update_html_usages.py index 7c73d339f..9c40e79e3 100644 --- a/tests/test__console/test_update_html_usages.py +++ b/tests/test__console/test_update_html_usages.py @@ -38,8 +38,8 @@ def test_update_html_usages(tmp_path): "html.div(**{variable: 'test', **other, 'key': value})", ), ( - 'html.div(dict(other, className="test"))', - "html.div(**other, class_name='test')", + 'html.div(dict(other, className="test", **another))', + "html.div(**other, class_name='test', **another)", ), ( 'html.div({"className": "outer"}, html.div({"className": "inner"}))', @@ -76,6 +76,23 @@ def test_update_html_usages(tmp_path): 'html.div({"tagName": "test"})', None, ), + ( + 'html.div(dict(tagName="test"))', + None, + ), + ( + '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, + ), # avoid unnecessary changes ( """ @@ -192,6 +209,9 @@ def func(): """, ), ], + 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()) From dcc98ec3bd59822f11ecef718f2b73c42dbc530b Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 26 Jan 2023 11:23:38 -0800 Subject: [PATCH 15/24] skip python<3.9 --- src/idom/_console/update_html_usages.py | 3 +++ tests/test__console/test_update_html_usages.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/src/idom/_console/update_html_usages.py b/src/idom/_console/update_html_usages.py index b9fc8ff6d..42d0ef93f 100644 --- a/src/idom/_console/update_html_usages.py +++ b/src/idom/_console/update_html_usages.py @@ -2,6 +2,7 @@ import ast import re +import sys from collections.abc import Sequence from dataclasses import dataclass from keyword import kwlist @@ -51,6 +52,8 @@ def update_html_usages(directories: list[str]) -> None: 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 d in directories: for file in Path(d).rglob("*.py"): result = generate_rewrite(file=file, source=file.read_text()) diff --git a/tests/test__console/test_update_html_usages.py b/tests/test__console/test_update_html_usages.py index 9c40e79e3..1e434e0f6 100644 --- a/tests/test__console/test_update_html_usages.py +++ b/tests/test__console/test_update_html_usages.py @@ -1,3 +1,4 @@ +import sys from pathlib import Path from textwrap import dedent @@ -213,6 +214,7 @@ def func(): if isinstance(item, str) else item, ) +@pytest.mark.skipif(sys.version_info < (3, 9), reason="ast.unparse is Python>=3.9") def test_generate_rewrite(source, expected): actual = generate_rewrite(Path("test.py"), dedent(source).strip()) if isinstance(expected, str): From aa26882db8fc0e288e71cd1db4dbfb5ba6949b3f Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 26 Jan 2023 11:59:35 -0800 Subject: [PATCH 16/24] misc fixes --- pyproject.toml | 2 +- src/idom/__main__.py | 15 +++--- src/idom/_console/update_html_usages.py | 70 ++++++++++++++----------- src/idom/_option.py | 3 +- src/idom/_warnings.py | 2 +- src/idom/core/types.py | 6 +-- src/idom/core/vdom.py | 20 +++++-- src/idom/html.py | 2 +- src/idom/utils.py | 1 - 9 files changed, 70 insertions(+), 51 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c7a429fd8..633c3156a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,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/src/idom/__main__.py b/src/idom/__main__.py index a2927e204..d8d07aa66 100644 --- a/src/idom/__main__.py +++ b/src/idom/__main__.py @@ -1,14 +1,17 @@ import click -from idom import __version__ +import idom from idom._console.update_html_usages import update_html_usages -app = click.Group( - commands=[ - 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/update_html_usages.py b/src/idom/_console/update_html_usages.py index 42d0ef93f..5fa9f08fb 100644 --- a/src/idom/_console/update_html_usages.py +++ b/src/idom/_console/update_html_usages.py @@ -13,7 +13,6 @@ from typing import Iterator import click -from typing_extensions import TypeGuard from idom import html @@ -61,42 +60,47 @@ def update_html_usages(directories: list[str]) -> None: file.write_text(result) -def generate_rewrite(file: Path, source: str) -> None: +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 isinstance(node, ast.Call) and node.args: - func = node.func - if isinstance(func, ast.Attribute): - name = func.attr - elif isinstance(func, ast.Name): - name = func.id - else: - continue - if hasattr(html, name) or name == "vdom": - if name == "vdom": - maybe_attr_dict_node = node.args[1] - # remove attr dict from new args - new_args = node.args[:1] + node.args[2:] + if not (isinstance(node, ast.Call) and node.args): + continue + + func = node.func + if isinstance(func, ast.Attribute): + name = func.attr + elif isinstance(func, ast.Name): + name = func.id + else: + continue + + if not (hasattr(html, name) or name == "vdom"): + continue + + if name == "vdom": + maybe_attr_dict_node = node.args[1] + # remove attr dict from new args + new_args = node.args[:1] + node.args[2:] + else: + maybe_attr_dict_node = node.args[0] + # remove attr dict from new args + new_args = node.args[1:] + + if node.args: + 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: - maybe_attr_dict_node = node.args[0] - # remove attr dict from new args - new_args = node.args[1:] - - if node.args: - 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.extend(new_keyword_info.keywords) - node.args = new_args - changed.append((node, *parents)) + node.args = new_args + changed.append((node, *parents)) if not changed: - return + return None ast.fix_missing_locations(tree) @@ -124,7 +128,10 @@ def generate_rewrite(file: Path, source: str) -> None: ) outermost_nodes_to_unparse = [current_outermost_node] for node in sorted_nodes_to_unparse: - if node.lineno > current_outermost_node.end_lineno: + 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) @@ -192,6 +199,7 @@ def extract_keywords(node: ast.AST) -> KeywordInfo | None: else: keywords.append(kw) return KeywordInfo(replace=False, keywords=keywords) + return None def find_comments(lines: list[str]) -> list[str]: @@ -220,4 +228,4 @@ def conv_attr_name(name: str) -> str: @dataclass class KeywordInfo: replace: bool - keywords: Sequence[ast.keyword] + keywords: list[ast.keyword] diff --git a/src/idom/_option.py b/src/idom/_option.py index 2c04c6761..50df79b11 100644 --- a/src/idom/_option.py +++ b/src/idom/_option.py @@ -2,8 +2,7 @@ import os from logging import getLogger -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 diff --git a/src/idom/_warnings.py b/src/idom/_warnings.py index 261e63bf5..827b66be0 100644 --- a/src/idom/_warnings.py +++ b/src/idom/_warnings.py @@ -8,7 +8,7 @@ @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) + _warn(*args, stacklevel=_frame_depth_in_module() + 1, **kwargs) # type: ignore def _frame_depth_in_module() -> int: diff --git a/src/idom/core/types.py b/src/idom/core/types.py index 0beab45ec..397c37791 100644 --- a/src/idom/core/types.py +++ b/src/idom/core/types.py @@ -2,7 +2,6 @@ import sys from collections import namedtuple -from collections.abc import Sequence from types import TracebackType from typing import ( TYPE_CHECKING, @@ -15,6 +14,7 @@ Mapping, NamedTuple, Optional, + Sequence, Type, TypeVar, Union, @@ -101,7 +101,7 @@ 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[ @@ -211,7 +211,7 @@ class VdomDictConstructor(Protocol): def __call__( self, - *children: VdomChild, + *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 75407e2aa..6b4d5c5d4 100644 --- a/src/idom/core/vdom.py +++ b/src/idom/core/vdom.py @@ -4,6 +4,7 @@ from typing import Any, DefaultDict, Mapping, cast 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 @@ -19,6 +20,7 @@ ImportSourceDict, Key, VdomChild, + VdomChildren, VdomDict, VdomDictConstructor, VdomJson, @@ -130,7 +132,10 @@ def is_vdom(value: Any) -> bool: def vdom( - tag: str, *children: VdomChild, key: Key | None = None, **attributes: Any + tag: str, + *children: VdomChild | VdomChildren, + key: Key | None = None, + **attributes: Any, ) -> VdomDict: """A helper function for creating VDOM elements. @@ -179,7 +184,10 @@ def vdom( if _is_single_child(child): flattened_children.append(child) else: - flattened_children.extend(child) + # 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) @@ -199,7 +207,7 @@ def vdom( def with_import_source(element: VdomDict, import_source: ImportSourceDict) -> VdomDict: - return {**element, "importSource": import_source} + return {**element, "importSource": import_source} # type: ignore def make_vdom_constructor( @@ -214,7 +222,9 @@ def make_vdom_constructor( """ def constructor( - *children: VdomChild, key: Key | None = None, **attributes: Any + *children: VdomChild | VdomChildren, + key: Key | None = None, + **attributes: Any, ) -> VdomDict: if not allow_children and children: raise TypeError(f"{tag!r} nodes cannot have children.") @@ -273,7 +283,7 @@ def separate_attributes_and_event_handlers( return separated_attributes, flat_event_handlers_dict -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 a2174063a..8b4b3edfd 100644 --- a/src/idom/html.py +++ b/src/idom/html.py @@ -157,7 +157,7 @@ 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 make_vdom_constructor, vdom diff --git a/src/idom/utils.py b/src/idom/utils.py index 64bfc8a33..14a27e27f 100644 --- a/src/idom/utils.py +++ b/src/idom/utils.py @@ -1,6 +1,5 @@ from __future__ import annotations -import re from itertools import chain from typing import Any, Callable, Generic, Iterable, TypeVar, cast From 37822552b3b81a03965ea2d040c409dfb12acfcf Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 26 Jan 2023 12:07:42 -0800 Subject: [PATCH 17/24] better skip --- .../guides/creating-interfaces/html-with-idom/index.rst | 8 +++----- tests/test__console/test_update_html_usages.py | 5 ++++- 2 files changed, 7 insertions(+), 6 deletions(-) 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/tests/test__console/test_update_html_usages.py b/tests/test__console/test_update_html_usages.py index 1e434e0f6..26aa2f01e 100644 --- a/tests/test__console/test_update_html_usages.py +++ b/tests/test__console/test_update_html_usages.py @@ -8,6 +8,10 @@ from idom._console.update_html_usages import generate_rewrite, update_html_usages +if sys.version_info < (3, 9): + pytestmark = pytest.mark.skip(reason="ast.unparse is Python>=3.9") + + def test_update_html_usages(tmp_path): runner = CliRunner() @@ -214,7 +218,6 @@ def func(): if isinstance(item, str) else item, ) -@pytest.mark.skipif(sys.version_info < (3, 9), reason="ast.unparse is Python>=3.9") def test_generate_rewrite(source, expected): actual = generate_rewrite(Path("test.py"), dedent(source).strip()) if isinstance(expected, str): From c630bdc595db60fec42fc76271b16a3fe51185a7 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Fri, 27 Jan 2023 10:46:04 -0800 Subject: [PATCH 18/24] misc typing improvements + fix ico path --- docs/source/_custom_js/package-lock.json | 18 ++------ docs/source/_exts/custom_autosectionlabel.py | 5 +-- .../public/assets/idom-logo-square-small.svg | 45 +++++++++++++++++++ src/idom/core/component.py | 6 +-- src/idom/core/hooks.py | 4 +- src/idom/core/layout.py | 20 ++++----- src/idom/core/types.py | 22 +++------ src/idom/types.py | 2 - 8 files changed, 69 insertions(+), 53 deletions(-) create mode 100644 src/client/public/assets/idom-logo-square-small.svg 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/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 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + 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 397c37791..38a17481a 100644 --- a/src/idom/core/types.py +++ b/src/idom/core/types.py @@ -7,10 +7,7 @@ TYPE_CHECKING, Any, Callable, - Dict, Generic, - Iterable, - List, Mapping, NamedTuple, Optional, @@ -20,7 +17,7 @@ Union, ) -from typing_extensions import Literal, Protocol, TypedDict, runtime_checkable +from typing_extensions import Literal, Protocol, TypeAlias, TypedDict, runtime_checkable _Type = TypeVar("_Type") @@ -104,20 +101,13 @@ async def __aexit__( 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 @@ -142,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 @@ -170,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""" diff --git a/src/idom/types.py b/src/idom/types.py index 73ffef03b..2931e8636 100644 --- a/src/idom/types.py +++ b/src/idom/types.py @@ -20,7 +20,6 @@ RootComponentConstructor, State, VdomAttributes, - VdomAttributesAndChildren, VdomChild, VdomChildren, VdomDict, @@ -46,7 +45,6 @@ "RootComponentConstructor", "State", "VdomAttributes", - "VdomAttributesAndChildren", "VdomChild", "VdomChildren", "VdomDict", From 4e2c23ef9c54500000c5a03d760028176068793c Mon Sep 17 00:00:00 2001 From: rmorshea Date: Fri, 27 Jan 2023 10:53:20 -0800 Subject: [PATCH 19/24] error if no files --- src/idom/_console/update_html_usages.py | 8 ++++++++ src/idom/_console/utils.py | 5 +++++ tests/test__console/test_update_html_usages.py | 8 ++++++++ 3 files changed, 21 insertions(+) create mode 100644 src/idom/_console/utils.py diff --git a/src/idom/_console/update_html_usages.py b/src/idom/_console/update_html_usages.py index 5fa9f08fb..8de0555ff 100644 --- a/src/idom/_console/update_html_usages.py +++ b/src/idom/_console/update_html_usages.py @@ -15,6 +15,7 @@ import click from idom import html +from idom._console.utils import error CAMEL_CASE_SUB_PATTERN = re.compile(r"(? None: """ if sys.version_info < (3, 9): # pragma: no cover raise RuntimeError("This command requires Python>=3.9") + + at_leat_one_file = False for d in directories: for file in Path(d).rglob("*.py"): + at_leat_one_file = True result = generate_rewrite(file=file, source=file.read_text()) if result is not None: file.write_text(result) + if not at_leat_one_file: + error("Found no Python files in the given directories.") + sys.exit(1) + def generate_rewrite(file: Path, source: str) -> str | None: tree = ast.parse(source) diff --git a/src/idom/_console/utils.py b/src/idom/_console/utils.py new file mode 100644 index 000000000..963d31a84 --- /dev/null +++ b/src/idom/_console/utils.py @@ -0,0 +1,5 @@ +import click + + +def error(text: str) -> None: + click.echo(f"{click.style('Error', fg='red')}: {text}") diff --git a/tests/test__console/test_update_html_usages.py b/tests/test__console/test_update_html_usages.py index 26aa2f01e..e0fa5cf56 100644 --- a/tests/test__console/test_update_html_usages.py +++ b/tests/test__console/test_update_html_usages.py @@ -27,6 +27,14 @@ def test_update_html_usages(tmp_path): assert tempfile.read_text() == "html.div(class_name=test)" +def test_update_html_usages_no_files(tmp_path): + runner = CliRunner() + + result = runner.invoke(update_html_usages, "directory-does-no-exist") + assert result.exit_code == 1 + assert "Found no Python files" in result.stdout + + @pytest.mark.parametrize( "source, expected", [ From 4a923f3dc4edba9acf5748e29ffb627723c5d09d Mon Sep 17 00:00:00 2001 From: rmorshea Date: Fri, 27 Jan 2023 12:12:52 -0800 Subject: [PATCH 20/24] test py 3.11 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From 9f8b90c90bbd7bff9b2e4004b60793e5488f3886 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Fri, 27 Jan 2023 14:48:55 -0800 Subject: [PATCH 21/24] warn on dir does not exist --- src/idom/_console/update_html_usages.py | 31 ++++++++++++------- src/idom/_console/utils.py | 6 +++- .../test__console/test_update_html_usages.py | 19 +++++++----- 3 files changed, 36 insertions(+), 20 deletions(-) diff --git a/src/idom/_console/update_html_usages.py b/src/idom/_console/update_html_usages.py index 8de0555ff..9e4d070e8 100644 --- a/src/idom/_console/update_html_usages.py +++ b/src/idom/_console/update_html_usages.py @@ -15,16 +15,20 @@ import click from idom import html -from idom._console.utils import error +from idom._console.utils import echo_error, echo_warning CAMEL_CASE_SUB_PATTERN = re.compile(r"(? None: - """Rewrite files in the given directories to use the new html element API. +@click.argument( + "directories", + nargs=-1, + type=click.Path(exists=True, dir_okay=True, file_okay=False), +) +def update_html_usages(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 constructor functions. For example: @@ -55,16 +59,19 @@ def update_html_usages(directories: list[str]) -> None: if sys.version_info < (3, 9): # pragma: no cover raise RuntimeError("This command requires Python>=3.9") - at_leat_one_file = False - for d in directories: - for file in Path(d).rglob("*.py"): - at_leat_one_file = True - result = generate_rewrite(file=file, source=file.read_text()) + at_least_one_file = False + for p in map(Path, paths): + if not p.exists(): + echo_warning(f"no directory {p}") + continue + for f in [p] if p.is_file() else p.rglob("*.py"): + at_least_one_file = True + result = generate_rewrite(file=f, source=f.read_text()) if result is not None: - file.write_text(result) + f.write_text(result) - if not at_leat_one_file: - error("Found no Python files in the given directories.") + if not at_least_one_file: + echo_error("Found no Python files in the given directories.") sys.exit(1) diff --git a/src/idom/_console/utils.py b/src/idom/_console/utils.py index 963d31a84..72c82cab7 100644 --- a/src/idom/_console/utils.py +++ b/src/idom/_console/utils.py @@ -1,5 +1,9 @@ import click -def error(text: str) -> None: +def echo_error(text: str) -> None: click.echo(f"{click.style('Error', fg='red')}: {text}") + + +def echo_warning(text: str) -> None: + click.echo(f"{click.style('Warning', fg='yellow')}: {text}") diff --git a/tests/test__console/test_update_html_usages.py b/tests/test__console/test_update_html_usages.py index e0fa5cf56..c2e90d6e9 100644 --- a/tests/test__console/test_update_html_usages.py +++ b/tests/test__console/test_update_html_usages.py @@ -17,20 +17,25 @@ def test_update_html_usages(tmp_path): tempfile: Path = tmp_path / "temp.py" tempfile.write_text("html.div({'className': test})") - - result = runner.invoke(update_html_usages, str(tmp_path)) - - if result.exception: - raise result.exception + result = runner.invoke( + update_html_usages, + args=[str(tmp_path)], + catch_exceptions=False, + ) assert result.exit_code == 0 assert tempfile.read_text() == "html.div(class_name=test)" -def test_update_html_usages_no_files(tmp_path): +def test_update_html_usages_no_files(): runner = CliRunner() - result = runner.invoke(update_html_usages, "directory-does-no-exist") + result = runner.invoke( + update_html_usages, + args=["directory-does-no-exist"], + catch_exceptions=False, + ) + assert result.exit_code == 1 assert "Found no Python files" in result.stdout From 0d54f4de0b0a414a3f0b1dde676a3affa34bfc9e Mon Sep 17 00:00:00 2001 From: rmorshea Date: Fri, 27 Jan 2023 15:10:58 -0800 Subject: [PATCH 22/24] fix tests --- src/idom/_console/update_html_usages.py | 44 +++++++------------ src/idom/_console/utils.py | 9 ---- .../test__console/test_update_html_usages.py | 15 ++++--- 3 files changed, 27 insertions(+), 41 deletions(-) delete mode 100644 src/idom/_console/utils.py diff --git a/src/idom/_console/update_html_usages.py b/src/idom/_console/update_html_usages.py index 9e4d070e8..21fc22bf8 100644 --- a/src/idom/_console/update_html_usages.py +++ b/src/idom/_console/update_html_usages.py @@ -15,18 +15,13 @@ import click from idom import html -from idom._console.utils import echo_error, echo_warning CAMEL_CASE_SUB_PATTERN = re.compile(r"(? None: """Rewrite files under the given paths using the new html element API. @@ -61,26 +56,19 @@ def update_html_usages(paths: list[str]) -> None: at_least_one_file = False for p in map(Path, paths): - if not p.exists(): - echo_warning(f"no directory {p}") - continue for f in [p] if p.is_file() else p.rglob("*.py"): at_least_one_file = True result = generate_rewrite(file=f, source=f.read_text()) if result is not None: f.write_text(result) - if not at_least_one_file: - echo_error("Found no Python files in the given directories.") - sys.exit(1) - 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) and node.args): + if not isinstance(node, ast.Call): continue func = node.func @@ -91,28 +79,30 @@ def generate_rewrite(file: Path, source: str) -> str | None: else: continue - if not (hasattr(html, name) or name == "vdom"): - 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:] - else: + 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 - if node.args: - 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) + 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)) + node.args = new_args + changed.append((node, *parents)) if not changed: return None diff --git a/src/idom/_console/utils.py b/src/idom/_console/utils.py deleted file mode 100644 index 72c82cab7..000000000 --- a/src/idom/_console/utils.py +++ /dev/null @@ -1,9 +0,0 @@ -import click - - -def echo_error(text: str) -> None: - click.echo(f"{click.style('Error', fg='red')}: {text}") - - -def echo_warning(text: str) -> None: - click.echo(f"{click.style('Warning', fg='yellow')}: {text}") diff --git a/tests/test__console/test_update_html_usages.py b/tests/test__console/test_update_html_usages.py index c2e90d6e9..2b5bb34c4 100644 --- a/tests/test__console/test_update_html_usages.py +++ b/tests/test__console/test_update_html_usages.py @@ -31,13 +31,10 @@ def test_update_html_usages_no_files(): runner = CliRunner() result = runner.invoke( - update_html_usages, - args=["directory-does-no-exist"], - catch_exceptions=False, + update_html_usages, args=["directory-does-no-exist"], catch_exceptions=False ) - assert result.exit_code == 1 - assert "Found no Python files" in result.stdout + assert result.exit_code != 0 @pytest.mark.parametrize( @@ -90,6 +87,14 @@ def test_update_html_usages_no_files(): 'html.div(ignore, {"className": "test"})', None, ), + ( + "html.div()", + None, + ), + ( + 'html.vdom("div")', + None, + ), ( 'html.div({"tagName": "test"})', None, From 8d2268d88393d58b7c61202d03290825608cff88 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Fri, 27 Jan 2023 15:32:14 -0800 Subject: [PATCH 23/24] fix old class_name typos --- docs/examples.py | 2 +- docs/source/reference/_examples/simple_dashboard.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/examples.py b/docs/examples.py index 05a4c0616..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(text, class_="printout") 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/reference/_examples/simple_dashboard.py b/docs/source/reference/_examples/simple_dashboard.py index 504ff6d1a..4f7ce2a42 100644 --- a/docs/source/reference/_examples/simple_dashboard.py +++ b/docs/source/reference/_examples/simple_dashboard.py @@ -86,7 +86,7 @@ def update_value(value): 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_="number-input-container", + class_name="number-input-container", ) From e8c3044a050ac64b2a944b2ca8dd6209605c14b6 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Fri, 27 Jan 2023 15:36:36 -0800 Subject: [PATCH 24/24] remove unused var --- src/idom/_console/update_html_usages.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/idom/_console/update_html_usages.py b/src/idom/_console/update_html_usages.py index 21fc22bf8..f824df56f 100644 --- a/src/idom/_console/update_html_usages.py +++ b/src/idom/_console/update_html_usages.py @@ -54,10 +54,8 @@ def update_html_usages(paths: list[str]) -> None: if sys.version_info < (3, 9): # pragma: no cover raise RuntimeError("This command requires Python>=3.9") - at_least_one_file = False for p in map(Path, paths): for f in [p] if p.is_file() else p.rglob("*.py"): - at_least_one_file = True result = generate_rewrite(file=f, source=f.read_text()) if result is not None: f.write_text(result)