Skip to content

Make Index Default Key #579

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jan 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@


file = Path(__file__).parent / "super-simple-chart.js"
ssc = web.module_from_file("super-simple-chart", file, fallback="⌛")
ssc = web.module_from_file(
"super-simple-chart",
file,
fallback="⌛",
# normally this option is not required
replace_existing=True,
)
SuperSimpleChart = web.export(ssc, "SuperSimpleChart")


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ def poly_coef_input(index, callback):
"onChange": callback,
},
),
key=index,
)


Expand Down
25 changes: 20 additions & 5 deletions docs/source/reference-material/_examples/snake_game.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ def GameView():
game_state, set_game_state = idom.hooks.use_state(GameState.init)

if game_state == GameState.play:
return GameLoop(grid_size=6, block_scale=50, set_game_state=set_game_state)
return GameLoop(
grid_size=6,
block_scale=50,
set_game_state=set_game_state,
key="game loop",
)

start_button = idom.html.button(
{"onClick": lambda event: set_game_state(GameState.play)},
Expand All @@ -40,7 +45,12 @@ def GameView():
"""
)

return idom.html.div({"className": "snake-game-menu"}, menu_style, menu)
return idom.html.div(
{"className": "snake-game-menu"},
menu_style,
menu,
key="menu",
)


class Direction(enum.Enum):
Expand Down Expand Up @@ -154,14 +164,18 @@ def create_grid(grid_size, block_scale):
[
idom.html.div(
{"style": {"height": f"{block_scale}px"}},
[create_grid_block("black", block_scale) for i in range(grid_size)],
[
create_grid_block("black", block_scale, key=i)
for i in range(grid_size)
],
key=i,
)
for i in range(grid_size)
],
)


def create_grid_block(color, block_scale):
def create_grid_block(color, block_scale, key):
return idom.html.div(
{
"style": {
Expand All @@ -170,7 +184,8 @@ def create_grid_block(color, block_scale):
"backgroundColor": color,
"outline": "1px solid grey",
}
}
},
key=key,
)


Expand Down
22 changes: 6 additions & 16 deletions src/idom/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

from pathlib import Path
from tempfile import TemporaryDirectory
from warnings import warn

from ._option import Option as _Option

Expand Down Expand Up @@ -58,25 +57,16 @@

IDOM_FEATURE_INDEX_AS_DEFAULT_KEY = _Option(
"IDOM_FEATURE_INDEX_AS_DEFAULT_KEY",
default=False,
default=True,
mutable=False,
validator=lambda x: bool(int(x)),
)
"""Use the index of elements/components amongst their siblings as the default key.

In a future release this flag's default value will be set to true, and after that, this
flag will be removed entirely and the indices will always be the default key.
The flag's default value is set to true. To return to legacy behavior set
``IDOM_FEATURE_INDEX_AS_DEFAULT_KEY=0``. In a future release, this flag will be removed
entirely and the indices will always be the default key.

For more information on changes to this feature flag see: https://github.com/idom-team/idom/issues/351
For more information on changes to this feature flag see:
https://github.com/idom-team/idom/issues/351
"""

if not IDOM_FEATURE_INDEX_AS_DEFAULT_KEY.current:
warn(
"In the next release, the feature flag IDOM_FEATURE_INDEX_AS_DEFAULT_KEY will "
"be activated by default. To try this out before the next release simply set "
"IDOM_FEATURE_INDEX_AS_DEFAULT_KEY=1 as an environment variable. After this "
"change, you can revert to the old behavior by setting it to 0 instead. If you "
"have questions or issues with this change report them here: "
"https://github.com/idom-team/idom/issues/351",
DeprecationWarning,
)
4 changes: 2 additions & 2 deletions src/idom/core/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -698,10 +698,10 @@ def _process_child_type_and_key(

if IDOM_FEATURE_INDEX_AS_DEFAULT_KEY.current:

def _default_key(index: int) -> Any: # pragma: no cover
def _default_key(index: int) -> Any:
return index

else:

def _default_key(index: int) -> Any:
def _default_key(index: int) -> Any: # pragma: no cover
return object()
9 changes: 6 additions & 3 deletions src/idom/core/proto.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,14 @@
"""Simple function returning a new component"""


Key = Union[str, int]


@runtime_checkable
class ComponentType(Protocol):
"""The expected interface for all component-like objects"""

key: Optional[Any]
key: Key | None
"""An identifier which is unique amongst a component's immediate siblings"""

def render(self) -> VdomDict:
Expand Down Expand Up @@ -74,7 +77,7 @@ def __exit__(


class _VdomDictOptional(TypedDict, total=False):
key: str
key: Key | None
children: Sequence[
# recursive types are not allowed yet:
# https://github.com/python/mypy/issues/731
Expand All @@ -101,7 +104,7 @@ class ImportSourceDict(TypedDict):


class _OptionalVdomJson(TypedDict, total=False):
key: str
key: Key
error: str
children: List[Any]
attributes: Dict[str, Any]
Expand Down
14 changes: 9 additions & 5 deletions src/idom/core/vdom.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ def is_vdom(value: Any) -> bool:
def vdom(
tag: str,
*attributes_and_children: VdomAttributesAndChildren,
key: str = "",
key: str | int | None = None,
event_handlers: Optional[EventHandlerMapping] = None,
import_source: Optional[ImportSourceDict] = None,
) -> VdomDict:
Expand Down Expand Up @@ -169,7 +169,7 @@ def vdom(
if event_handlers:
model["eventHandlers"] = event_handlers

if key != "":
if key is not None:
model["key"] = key

if import_source is not None:
Expand All @@ -182,7 +182,7 @@ class _VdomDictConstructor(Protocol):
def __call__(
self,
*attributes_and_children: VdomAttributesAndChildren,
key: str = ...,
key: str | int | None = ...,
event_handlers: Optional[EventHandlerMapping] = ...,
import_source: Optional[ImportSourceDict] = ...,
) -> VdomDict:
Expand All @@ -200,7 +200,7 @@ def make_vdom_constructor(

def constructor(
*attributes_and_children: VdomAttributesAndChildren,
key: str = "",
key: str | int | None = None,
event_handlers: Optional[EventHandlerMapping] = None,
import_source: Optional[ImportSourceDict] = None,
) -> VdomDict:
Expand Down Expand Up @@ -333,7 +333,11 @@ def _is_single_child(value: Any) -> bool:
logger.error(f"Key not specified for child in list {child}")
elif isinstance(child, Mapping) and "key" not in child:
# remove 'children' to reduce log spam
child_copy = {**child, "children": ...}
child_copy = {**child, "children": _EllipsisRepr()}
logger.error(f"Key not specified for child in list {child_copy}")

return False

class _EllipsisRepr:
def __repr__(self) -> str:
return "..."
107 changes: 80 additions & 27 deletions src/idom/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
Generic,
Iterator,
List,
NoReturn,
Optional,
Tuple,
Type,
Expand Down Expand Up @@ -173,6 +174,10 @@ def __exit__(
return None


class LogAssertionError(AssertionError):
"""An assertion error raised in relation to log messages."""


@contextmanager
def assert_idom_logged(
match_message: str = "",
Expand All @@ -192,11 +197,13 @@ def assert_idom_logged(
error_pattern = re.compile(match_error)

try:
with capture_idom_logs() as handler:
with capture_idom_logs(yield_existing=clear_matched_records) as log_records:
yield None
finally:
except Exception:
raise
else:
found = False
for record in list(handler.records):
for record in list(log_records):
if (
# record message matches
message_pattern.findall(record.getMessage())
Expand All @@ -222,36 +229,85 @@ def assert_idom_logged(
):
found = True
if clear_matched_records:
handler.records.remove(record)
log_records.remove(record)

if not found: # pragma: no cover
conditions = []
if match_message:
conditions.append(f"log message pattern {match_message!r}")
if error_type:
conditions.append(f"exception type {error_type}")
if match_error:
conditions.append(f"error message pattern {match_error!r}")
raise AssertionError(
"Could not find a log record matching the given "
+ " and ".join(conditions)
_raise_log_message_error(
"Could not find a log record matching the given",
match_message,
error_type,
match_error,
)


@contextmanager
def capture_idom_logs() -> Iterator[_LogRecordCaptor]:
"""Capture logs from IDOM"""
if _LOG_RECORD_CAPTOR_SINGLTON in ROOT_LOGGER.handlers:
# this is being handled by an outer capture context
yield _LOG_RECORD_CAPTOR_SINGLTON
return None
def assert_idom_did_not_log(
match_message: str = "",
error_type: type[Exception] | None = None,
match_error: str = "",
clear_matched_records: bool = False,
) -> Iterator[None]:
"""Assert the inverse of :func:`assert_idom_logged`"""
try:
with assert_idom_logged(
match_message, error_type, match_error, clear_matched_records
):
yield None
except LogAssertionError:
pass
else:
_raise_log_message_error(
"Did find a log record matching the given",
match_message,
error_type,
match_error,
)

ROOT_LOGGER.addHandler(_LOG_RECORD_CAPTOR_SINGLTON)

def _raise_log_message_error(
prefix: str,
match_message: str = "",
error_type: type[Exception] | None = None,
match_error: str = "",
) -> NoReturn:
conditions = []
if match_message:
conditions.append(f"log message pattern {match_message!r}")
if error_type:
conditions.append(f"exception type {error_type}")
if match_error:
conditions.append(f"error message pattern {match_error!r}")
raise LogAssertionError(prefix + " " + " and ".join(conditions))


@contextmanager
def capture_idom_logs(
yield_existing: bool = False,
) -> Iterator[list[logging.LogRecord]]:
"""Capture logs from IDOM

Parameters:
yield_existing:
If already inside an existing capture context yield the same list of logs.
This is useful if you need to mutate the list of logs to affect behavior in
the outer context.
"""
if yield_existing:
for handler in reversed(ROOT_LOGGER.handlers):
if isinstance(handler, _LogRecordCaptor):
yield handler.records
return None

handler = _LogRecordCaptor()
original_level = ROOT_LOGGER.level

ROOT_LOGGER.setLevel(logging.DEBUG)
ROOT_LOGGER.addHandler(handler)
try:
yield _LOG_RECORD_CAPTOR_SINGLTON
yield handler.records
finally:
ROOT_LOGGER.removeHandler(_LOG_RECORD_CAPTOR_SINGLTON)
_LOG_RECORD_CAPTOR_SINGLTON.records = []
ROOT_LOGGER.removeHandler(handler)
ROOT_LOGGER.setLevel(original_level)


class _LogRecordCaptor(logging.NullHandler):
Expand All @@ -264,9 +320,6 @@ def handle(self, record: logging.LogRecord) -> bool:
return True


_LOG_RECORD_CAPTOR_SINGLTON = _LogRecordCaptor()


class HookCatcher:
"""Utility for capturing a LifeCycleHook from a component

Expand Down
4 changes: 2 additions & 2 deletions src/idom/web/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,8 @@ def module_from_file(
Using this option has negative performance consequences since all DOM
elements must be changed on each render. See :issue:`461` for more info.
replace_existing:
Whether to replace the source for a module with the same name if
if already exists. Otherwise raise an error.
Whether to replace the source for a module with the same name if it already
exists. Otherwise raise an error.
"""
source_file = Path(file)
target_file = _web_module_path(name)
Expand Down
Loading