Skip to content

fix bug in element key identity #563

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 9 commits into from
Jan 4, 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
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
run: pip install -r requirements/nox-deps.txt
- name: Run Tests
env: { "CI": "true" }
run: nox -s test_python_suite -- --headless
run: nox -s test_python_suite -- --headless --maxfail=3
test-python-environments:
runs-on: ${{ matrix.os }}
strategy:
Expand All @@ -48,7 +48,7 @@ jobs:
run: pip install -r requirements/nox-deps.txt
- name: Run Tests
env: { "CI": "true" }
run: nox -s test_python -- --headless --no-cov
run: nox -s test_python --stop-on-first-error -- --headless --maxfail=3 --no-cov
test-docs:
runs-on: ubuntu-latest
steps:
Expand Down
2 changes: 2 additions & 0 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ def test_python_suite(session: Session) -> None:
install_requirements_file(session, "test-env")

posargs = session.posargs
posargs += ["--reruns", "3", "--reruns-delay", "1"]

if "--no-cov" in session.posargs:
session.log("Coverage won't be checked")
session.install(".[all]")
Expand Down
2 changes: 1 addition & 1 deletion requirements/pkg-deps.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
typing-extensions >=3.10
mypy-extensions >=0.4.3
anyio >=3.0
jsonpatch >=1.26
jsonpatch >=1.32
fastjsonschema >=2.14.5
requests >=2.0
5 changes: 3 additions & 2 deletions requirements/test-env.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
ipython
pytest
pytest-asyncio
pytest-cov
pytest-mock
pytest-rerunfailures
pytest-timeout
selenium
ipython
responses
selenium
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ exclude_lines =
raise NotImplementedError
omit =
src/idom/__main__.py
src/idom/core/_fixed_jsonpatch.py

[build_sphinx]
all-files = true
Expand Down
56 changes: 56 additions & 0 deletions src/idom/core/_fixed_jsonpatch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# type: ignore

"""A patched version of jsonpatch

We need this because of: https://github.com/stefankoegl/python-json-patch/issues/138

The core of this patch is in `DiffBuilder._item_removed`. The rest is just boilerplate
that's been copied over with little to no changes.
"""

from jsonpatch import _ST_REMOVE
from jsonpatch import DiffBuilder as _DiffBuilder
from jsonpatch import JsonPatch as _JsonPatch
from jsonpatch import RemoveOperation, _path_join, basestring
from jsonpointer import JsonPointer


def apply_patch(doc, patch, in_place=False, pointer_cls=JsonPointer):
if isinstance(patch, basestring):
patch = JsonPatch.from_string(patch, pointer_cls=pointer_cls)
else:
patch = JsonPatch(patch, pointer_cls=pointer_cls)
return patch.apply(doc, in_place)


def make_patch(src, dst, pointer_cls=JsonPointer):
return JsonPatch.from_diff(src, dst, pointer_cls=pointer_cls)


class JsonPatch(_JsonPatch):
@classmethod
def from_diff(
cls,
src,
dst,
optimization=True,
dumps=None,
pointer_cls=JsonPointer,
):
json_dumper = dumps or cls.json_dumper
builder = DiffBuilder(src, dst, json_dumper, pointer_cls=pointer_cls)
builder._compare_values("", None, src, dst)
ops = list(builder.execute())
return cls(ops, pointer_cls=pointer_cls)


class DiffBuilder(_DiffBuilder):
def _item_removed(self, path, key, item):
new_op = RemoveOperation(
{
"op": "remove",
"path": _path_join(path, key),
}
)
new_index = self.insert(new_op)
self.store_index(item, new_index, _ST_REMOVE)
2 changes: 1 addition & 1 deletion src/idom/core/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@
from weakref import WeakSet

from anyio import create_task_group
from jsonpatch import apply_patch, make_patch

from idom.utils import Ref

from ._fixed_jsonpatch import apply_patch, make_patch # type: ignore
from .layout import LayoutEvent, LayoutUpdate
from .proto import LayoutType, VdomJson

Expand Down
7 changes: 3 additions & 4 deletions src/idom/core/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,9 @@ def _render_component(
else:
key, index = new_state.key, new_state.index
if old_state is not None:
assert (key, index) == (old_state.key, old_state.index,), (
assert key == old_state.key, (
"state mismatch during component update - "
f"key {key!r}!={old_state.key} "
f"or index {index}!={old_state.index}"
f"key {key!r}!={old_state.key!r} "
)
parent.children_by_key[key] = new_state
# need to do insertion in case where old_state is None and we're appending
Expand Down Expand Up @@ -538,7 +537,7 @@ def _update_element_model_state(
key=old_model_state.key,
model=Ref(), # does not copy the model
patch_path=old_model_state.patch_path,
children_by_key=old_model_state.children_by_key.copy(),
children_by_key={},
targets_by_event={},
)

Expand Down
15 changes: 6 additions & 9 deletions src/idom/log.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
import logging
import sys
from logging.config import dictConfig
from typing import Any

from .config import IDOM_DEBUG_MODE


root_logger = logging.getLogger("idom")


def logging_config_defaults() -> Any:
"""Get default logging configuration"""
return {
dictConfig(
{
"version": 1,
"disable_existing_loggers": False,
"loggers": {
Expand All @@ -35,10 +30,12 @@ def logging_config_defaults() -> Any:
}
},
}
)


dictConfig(logging_config_defaults())
ROOT_LOGGER = logging.getLogger("idom")
"""IDOM's root logger instance"""


if IDOM_DEBUG_MODE.current:
root_logger.debug("IDOM is in debug mode")
ROOT_LOGGER.debug("IDOM is in debug mode")
91 changes: 91 additions & 0 deletions src/idom/testing.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
from __future__ import annotations

import logging
import re
import shutil
from contextlib import contextmanager
from functools import wraps
from traceback import format_exception
from types import TracebackType
from typing import (
Any,
Callable,
Dict,
Generic,
Iterator,
List,
Optional,
Tuple,
Expand All @@ -29,6 +34,8 @@
from idom.server.proto import ServerFactory, ServerType
from idom.server.utils import find_available_port

from .log import ROOT_LOGGER


__all__ = [
"find_available_port",
Expand Down Expand Up @@ -166,6 +173,87 @@ def __exit__(
return None


@contextmanager
def assert_idom_logged(
match_message: str = "",
error_type: type[Exception] | None = None,
match_error: str = "",
clear_matched_records: bool = False,
) -> Iterator[None]:
"""Assert that IDOM produced a log matching the described message or error.

Args:
match_message: Must match a logged message.
error_type: Checks the type of logged exceptions.
match_error: Must match an error message.
clear_matched_records: Whether to remove logged records that match.
"""
message_pattern = re.compile(match_message)
error_pattern = re.compile(match_error)

try:
with capture_idom_logs() as handler:
yield None
finally:
found = False
for record in list(handler.records):
if (
# record message matches
message_pattern.findall(record.getMessage())
# error type matches
and (
error_type is None
or (
record.exc_info is not None
and record.exc_info[0] is not None
and issubclass(record.exc_info[0], error_type)
)
)
# error message pattern matches
and (
not match_error
or (
record.exc_info is not None
and error_pattern.findall(
"".join(format_exception(*record.exc_info))
)
)
)
):
found = True
if clear_matched_records:
handler.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)
)


@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

ROOT_LOGGER.addHandler(_LOG_RECORD_CAPTOR_SINGLTON)
try:
yield _LOG_RECORD_CAPTOR_SINGLTON
finally:
ROOT_LOGGER.removeHandler(_LOG_RECORD_CAPTOR_SINGLTON)
_LOG_RECORD_CAPTOR_SINGLTON.records = []


class _LogRecordCaptor(logging.NullHandler):
def __init__(self) -> None:
self.records: List[logging.LogRecord] = []
Expand All @@ -176,6 +264,9 @@ 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
Loading