Skip to content
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
2 changes: 1 addition & 1 deletion .github/workflows/test_and_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ jobs:

- name: Install and Run Tests
run: |
pip install ".[dev]"
pip install -e ".[dev]"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you need -e that probably mean we are not properly installing something?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The -e is only needed so that the coverage reports outputs something more than 0% since moving the files to src.
My previous fix of the src path for the coverage didn't resolve the problem but installing using -e did.

Locally I can also run the tests without installing and providing the PYTHON_PATH with ./src to get coverage as well so I suppose the problem is coming from where the coverage expects the files to be.

I'm open to suggestions on this!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only thing that I'm saying is that if we mess up something on the install part, we could miss it because of that. But I'm ok to merge for now.

pip install -r tests/requirements.txt
# Run the tests with coverage so we get a coverage report too
pip install coverage
Expand Down
55 changes: 45 additions & 10 deletions src/trame_server/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import weakref
from collections import deque
from contextlib import contextmanager
from typing import Any, Iterable, Iterator

from .utils import asynchronous, is_dunder, is_private, share
from .utils.hot_reload import reload
Expand Down Expand Up @@ -43,10 +44,44 @@ def flushing_context(self):
self.flushing = False


class _OrderedSet:
"""
Lightweight ordered set implementation based on dict to preserve insertion order
without external dependencies.
"""

def __init__(self, *args: Any) -> None:
self._data: dict[Any, None] = {}
for arg in args:
self.add(arg)

def __bool__(self) -> bool:
return bool(self._data)

def __contains__(self, key: Any) -> bool:
return key in self._data

def __iter__(self) -> Iterator[Any]:
return iter(self._data)

def add(self, key: Any) -> None:
self._data[key] = None

def clear(self) -> None:
self._data.clear()

def discard(self, key: Any) -> None:
self._data.pop(key, None)

def update(self, iterable: Iterable[Any]) -> None:
for item in iterable:
self.add(item)


class StateChangeHandler:
def __init__(self, listeners):
self._all_listeners = listeners
self._currents = set()
self._currents = _OrderedSet()

def add(self, key):
if key in self._all_listeners:
Expand All @@ -70,9 +105,9 @@ class _SuppressListenersChangeStack:
"""

def __init__(self):
self._deque: deque[set[str]] = deque()
self._suppressed_keys: set[str] | None = None
self._listener_keys: set[str] = set()
self._deque: deque[_OrderedSet] = deque()
self._suppressed_keys: _OrderedSet | None = None
self._listener_keys: _OrderedSet = _OrderedSet()

def on_pending_key_added(self, key: str) -> None:
if not self._is_suppressed(key):
Expand All @@ -82,7 +117,7 @@ def on_pending_key_removed(self, key: str) -> None:
self._listener_keys.discard(key)

def push(self, *keys: str) -> None:
self._deque.append(set(keys))
self._deque.append(_OrderedSet(*keys))
self._update_suppressed_keys()

def pop(self) -> None:
Expand All @@ -93,15 +128,15 @@ def pop(self) -> None:
self._update_suppressed_keys()

def clear(self) -> None:
self._listener_keys = set()
self._listener_keys = _OrderedSet()

def get_change_listener_keys(self) -> set[str]:
def get_change_listener_keys(self) -> _OrderedSet:
return self._listener_keys

def _is_suppressed(self, key: str) -> bool:
if self._suppressed_keys is None:
return False
if self._suppressed_keys == set():
if not self._suppressed_keys:
return True
return key in self._suppressed_keys

Expand All @@ -116,9 +151,9 @@ def _update_suppressed_keys(self) -> None:
self._suppressed_keys = None
return

self._suppressed_keys = set()
self._suppressed_keys = _OrderedSet()
for d_set in self._deque:
if d_set == set():
if not d_set:
self._suppressed_keys.clear()
return
self._suppressed_keys.update(d_set)
Expand Down
27 changes: 27 additions & 0 deletions tests/test_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -698,3 +698,30 @@ def on_change(a, **_):

state.flush()
mock.assert_not_called()


def test_state_change_listeners_are_triggered_in_modified_order(state):
keys = [f"k_{i}" for i in range(10)]
recorded = []

def create_listener(k):
def on_change(**_):
recorded.append(k)

return on_change

for key in keys:
state.change(key)(create_listener(key))

for i_trial in range(1000):
recorded.clear()

for key in keys:
state[key] = i_trial

state.flush()

_assert_msg = (
f"Order mismatch at iteration {i_trial}: expected {keys}, got {recorded}"
)
assert recorded == keys, _assert_msg
Loading