Skip to content
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

Test flakiness investigation and attempted fixes ❄ #3498

Merged
merged 61 commits into from
Oct 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
6b02725
Modifying two flaky animation tests, hopefully removing flakiness :)
darrenburns Oct 10, 2023
37ff2cf
Make switch_mode return an AwaitMount
darrenburns Oct 10, 2023
f113057
Fix event issue
darrenburns Oct 10, 2023
be865f9
Merge branch 'main' of github.com:Textualize/textual into flaky-tests
darrenburns Oct 12, 2023
120feb0
Add AwaitComplete - a more generalised optionally awaitable object
darrenburns Oct 12, 2023
8525c53
Use AwaitComplete in Tabs.remove_tab() and update tests accordingly.
darrenburns Oct 12, 2023
2539df9
Update TabbedContent to use AwaitComplete instead of AwaitTabbedContent
darrenburns Oct 12, 2023
842bdf8
Simplifying - dont use optional awaitables where not required
darrenburns Oct 12, 2023
ab9f1cd
Update variable name
darrenburns Oct 12, 2023
65d7853
Update a comment
darrenburns Oct 12, 2023
0b4f3a9
Add watcher for cursor blink to ensure when blink is switched off, th…
darrenburns Oct 12, 2023
3741f59
Fix cursor blink reactive and disable cursor blink in the command pal…
darrenburns Oct 12, 2023
b73f69c
More progress
darrenburns Oct 12, 2023
86da626
Reworking AwaitComplete
darrenburns Oct 16, 2023
0faf7b2
Some more work on tabs flakiness/race-conditions
darrenburns Oct 16, 2023
8ecbe20
Merge branch 'main' of github.com:willmcgugan/textual into flaky-tests
darrenburns Oct 16, 2023
fa36cd4
Ensure active tab is set correctly
darrenburns Oct 16, 2023
c0fef47
Simplify next tab assignment
darrenburns Oct 16, 2023
2df0584
Simplify removing tabs logic
darrenburns Oct 16, 2023
5b0d78e
Make button animation duration configurable; Switch off button animat…
darrenburns Oct 16, 2023
cda2319
Remove a flawed test
darrenburns Oct 16, 2023
6c686ff
Add awaits in some tests
darrenburns Oct 16, 2023
68f2d2e
Docstrings
darrenburns Oct 16, 2023
d871c83
Make active_effect_duration an instance attribute
darrenburns Oct 16, 2023
710d6b5
Fix a Tabs crash
darrenburns Oct 16, 2023
1a7feec
Await the tree reload when the path changes in DirectoryTree
darrenburns Oct 16, 2023
b8428dd
Change AwaitComplete _instances class attr to a set from a list
darrenburns Oct 17, 2023
55c0950
Make AwaitComplete generic, AwaitComplete._wait_all is now private, a…
darrenburns Oct 17, 2023
ab28264
Actually make AwaitComplete instances a set, not a list
darrenburns Oct 17, 2023
4d64ca9
Update CHANGELOG.md regarding flaky-test adjacent changes, AwaitCompl…
darrenburns Oct 17, 2023
441882d
Remove whitespace
darrenburns Oct 17, 2023
91a5936
Merge branch 'main' of github.com:willmcgugan/textual into flaky-tests
darrenburns Oct 17, 2023
5238dd9
Use list() instead of useless comprehension, remove unused import
darrenburns Oct 17, 2023
7674b4f
Merge branch 'main' into flaky-tests
darrenburns Oct 17, 2023
0df54ef
Merge branch 'main' of github.com:willmcgugan/textual into flaky-tests
darrenburns Oct 18, 2023
63bcf41
Merge branch 'flaky-tests' of github.com:willmcgugan/textual into fla…
darrenburns Oct 18, 2023
d0f2971
Ensure loading indicator _start_time is initialised correctly
darrenburns Oct 18, 2023
eb15a59
Switch from time.sleep to asyncio.sleep in a notifications test, rewo…
darrenburns Oct 18, 2023
cba8978
Resolve deadlock by awaiting event on the event loop instead of in th…
darrenburns Oct 18, 2023
c837abe
Renaming for clarity
darrenburns Oct 18, 2023
6d06d2a
Debugging for remove_tab test flakiness
darrenburns Oct 19, 2023
1d4c28e
Running all tests
darrenburns Oct 19, 2023
c3766a9
Merge branch 'main' of github.com:willmcgugan/textual into flaky-tests
darrenburns Oct 24, 2023
2e93238
Updating snapshots
darrenburns Oct 24, 2023
5642681
Remove debugging prints
darrenburns Oct 24, 2023
5dd2f81
Fix broken docstring, remove unused import
darrenburns Oct 24, 2023
59572c7
Merge branch 'main' of github.com:willmcgugan/textual into flaky-tests
darrenburns Oct 24, 2023
62ac7e2
Rename variable to make it clearer
darrenburns Oct 24, 2023
a7bb953
Add missing return type annotation
darrenburns Oct 24, 2023
4b9bce3
Update src/textual/widgets/_tabbed_content.py
darrenburns Oct 24, 2023
0582f54
Update src/textual/widgets/_tabbed_content.py
darrenburns Oct 24, 2023
65c97c8
Update src/textual/widgets/_tabs.py
darrenburns Oct 24, 2023
b4de92b
Scroll datatable cursor after refresh
darrenburns Oct 25, 2023
9ec4680
Add comment explaining use of call_after_refresh when scrolling data …
darrenburns Oct 25, 2023
15c308d
Merge branch 'flaky-tests' of github.com:willmcgugan/textual into fla…
darrenburns Oct 25, 2023
547a918
Add repr to AwaitComplete (auto-repr_
darrenburns Oct 25, 2023
6104481
Remove use of generics from AwaitComplete
darrenburns Oct 25, 2023
d9feeda
Update changelog and improve docstring
darrenburns Oct 25, 2023
1238870
Add a missing parameter from DirectoryTree.reset_node docstring.
darrenburns Oct 25, 2023
c5be6af
Improve docstring in DirectoryTree
darrenburns Oct 25, 2023
c238dac
Rename parameter coroutine to coroutines in await_complete.py, since …
darrenburns Oct 25, 2023
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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

### Fixed

- Fixed `Input.cursor_blink` reactive not changing blink state after `Input` was mounted https://github.com/Textualize/textual/pull/3498
- Fixed `Tabs.active` attribute value not being re-assigned after removing a tab or clearing https://github.com/Textualize/textual/pull/3498
- Fixed `DirectoryTree` race-condition crash when changing path https://github.com/Textualize/textual/pull/3498
- Fixed issue with `LRUCache.discard` https://github.com/Textualize/textual/issues/3537
- Fixed `DataTable` not scrolling to rows that were just added https://github.com/Textualize/textual/pull/3552
- Fixed cache bug with `DataTable.update_cell` https://github.com/Textualize/textual/pull/3551
Expand All @@ -19,6 +22,19 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

### Changed

- Breaking change: `Button.ACTIVE_EFFECT_DURATION` classvar converted to `Button.active_effect_duration` attribute https://github.com/Textualize/textual/pull/3498
- Breaking change: `Input.blink_timer` made private (renamed to `Input._blink_timer`) https://github.com/Textualize/textual/pull/3498
- Breaking change: `Input.cursor_blink` reactive updated to not run on mount (now `init=False`) https://github.com/Textualize/textual/pull/3498
- Breaking change: `AwaitTabbedContent` class removed https://github.com/Textualize/textual/pull/3498
- Breaking change: `Tabs.remove_tab` now returns an `AwaitComplete` instead of an `AwaitRemove` https://github.com/Textualize/textual/pull/3498
- Breaking change: `Tabs.clear` now returns an `AwaitComplete` instead of an `AwaitRemove` https://github.com/Textualize/textual/pull/3498
- `TabbedContent.add_pane` now returns an `AwaitComplete` instead of an `AwaitTabbedContent` https://github.com/Textualize/textual/pull/3498
- `TabbedContent.remove_pane` now returns an `AwaitComplete` instead of an `AwaitTabbedContent` https://github.com/Textualize/textual/pull/3498
- `TabbedContent.clear_pane` now returns an `AwaitComplete` instead of an `AwaitTabbedContent` https://github.com/Textualize/textual/pull/3498
- `Tabs.add_tab` now returns an `AwaitComplete` instead of an `AwaitMount` https://github.com/Textualize/textual/pull/3498
- `DirectoryTree.reload` now returns an `AwaitComplete`, which may be awaited to ensure the node has finished being processed by the internal queue https://github.com/Textualize/textual/pull/3498
- `Tabs.remove_tab` now returns an `AwaitComplete`, which may be awaited to ensure the tab is unmounted and internal state is updated https://github.com/Textualize/textual/pull/3498
- `App.switch_mode` now returns an `AwaitMount`, which may be awaited to ensure the screen is mounted https://github.com/Textualize/textual/pull/3498
- Buttons will now display multiple lines, and have auto height https://github.com/Textualize/textual/pull/3539
- DataTable now has a max-height of 100vh rather than 100%, which doesn't work with auto
- Breaking change: empty rules now result in an error https://github.com/Textualize/textual/pull/3566
Expand All @@ -29,9 +45,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added `initial` to all css rules, which restores default (i.e. value from DEFAULT_CSS) https://github.com/Textualize/textual/pull/3566
- Added HorizontalPad to pad.py https://github.com/Textualize/textual/pull/3571

### Added

- Added `AwaitComplete` class, to be used for optionally awaitable return values https://github.com/Textualize/textual/pull/3498

## [0.40.0] - 2023-10-11

### Added

- Added `loading` reactive property to widgets https://github.com/Textualize/textual/pull/3509

## [0.39.0] - 2023-10-10
Expand Down
3 changes: 1 addition & 2 deletions src/textual/_animator.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ def _animate(
duration is None and speed is not None
), "An Animation should have a duration OR a speed"

# If an animation is already scheduled for this attribute, unschedule it.
animation_key = (id(obj), attribute)
try:
del self._scheduled[animation_key]
Expand All @@ -359,9 +360,7 @@ def _animate(
final_value = value

start_time = self._get_time()

easing_function = EASING[easing] if isinstance(easing, str) else easing

animation: Animation | None = None

if hasattr(obj, "__textual_animation__"):
Expand Down
28 changes: 23 additions & 5 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1597,28 +1597,40 @@ def mount_all(
"""
return self.mount(*widgets, before=before, after=after)

def _init_mode(self, mode: str) -> None:
def _init_mode(self, mode: str) -> AwaitMount:
"""Do internal initialisation of a new screen stack mode.

Args:
mode: Name of the mode.

Returns:
An optionally awaitable object which can be awaited until the screen
associated with the mode has been mounted.
"""

stack = self._screen_stacks.get(mode, [])
if not stack:
if stack:
await_mount = AwaitMount(stack[0], [])
else:
_screen = self.MODES[mode]
new_screen: Screen | str = _screen() if callable(_screen) else _screen
screen, _ = self._get_screen(new_screen)
screen, await_mount = self._get_screen(new_screen)
stack.append(screen)
self._load_screen_css(screen)

self._screen_stacks[mode] = stack
return await_mount

def switch_mode(self, mode: str) -> None:
def switch_mode(self, mode: str) -> AwaitMount:
"""Switch to a given mode.

Args:
mode: The mode to switch to.

Returns:
An optionally awaitable object which waits for the screen associated
with the mode to be mounted.

Raises:
UnknownModeError: If trying to switch to an unknown mode.
"""
Expand All @@ -1629,13 +1641,19 @@ def switch_mode(self, mode: str) -> None:
self.screen.refresh()

if mode not in self._screen_stacks:
self._init_mode(mode)
await_mount = self._init_mode(mode)
else:
await_mount = AwaitMount(self.screen, [])

self._current_mode = mode
self.screen._screen_resized(self.size)
self.screen.post_message(events.ScreenResume())

self.log.system(f"{self._current_mode!r} is the current mode")
self.log.system(f"{self.screen} is active")

return await_mount

def add_mode(
self, mode: str, base_screen: str | Screen | Callable[[], Screen]
) -> None:
Expand Down
48 changes: 48 additions & 0 deletions src/textual/await_complete.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from __future__ import annotations

from asyncio import Future, gather
from typing import Any, Coroutine, Iterator, TypeVar

import rich.repr

ReturnType = TypeVar("ReturnType")


@rich.repr.auto(angular=True)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I wonder if it would be helpful if the repr included the coroutines.

Maybe see what it generates. If it looks like noise, we can leave it as is.

Copy link
Member Author

Choose a reason for hiding this comment

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

Wouldn't it already include the coroutines, since the parameter is named the same as the attribute?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ah, you're right. What a nice feature. I must thank the dev who implemented that.

Curious what the repr will look like.

class AwaitComplete:
darrenburns marked this conversation as resolved.
Show resolved Hide resolved
"""An 'optionally-awaitable' object."""

def __init__(self, *coroutines: Coroutine[Any, Any, Any]) -> None:
"""Create an AwaitComplete.

Args:
coroutine: One or more coroutines to execute.
"""
self.coroutines = coroutines
self._future: Future = gather(*self.coroutines)

async def __call__(self) -> Any:
return await self

def __await__(self) -> Iterator[None]:
return self._future.__await__()

@property
def is_done(self) -> bool:
"""Returns True if the task has completed."""
return self._future.done()

@property
def exception(self) -> BaseException | None:
"""An exception if it occurred in any of the coroutines."""
if self._future.done():
return self._future.exception()
return None

@classmethod
def nothing(cls):
"""Returns an already completed instance of AwaitComplete."""
instance = cls()
instance._future = Future()
instance._future.set_result(None) # Mark it as completed with no result
return instance
1 change: 0 additions & 1 deletion src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from collections import Counter
from fractions import Fraction
from itertools import islice
from operator import attrgetter
from types import TracebackType
from typing import (
TYPE_CHECKING,
Expand Down
15 changes: 8 additions & 7 deletions src/textual/widgets/_button.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,6 @@ class Button(Static, can_focus=True):

BINDINGS = [Binding("enter", "press", "Press Button", show=False)]

ACTIVE_EFFECT_DURATION = 0.3
"""When buttons are clicked they get the `-active` class for this duration (in seconds)"""

label: reactive[TextType] = reactive[TextType]("")
"""The text label that appears within the button."""

Expand Down Expand Up @@ -211,6 +208,9 @@ def __init__(

self.variant = self.validate_variant(variant)

self.active_effect_duration = 0.3
"""Amount of time in seconds the button 'press' animation lasts."""

def __rich_repr__(self) -> rich.repr.Result:
yield from super().__rich_repr__()
yield "variant", self.variant, "default"
Expand Down Expand Up @@ -266,10 +266,11 @@ def press(self) -> Self:

def _start_active_affect(self) -> None:
"""Start a small animation to show the button was clicked."""
self.add_class("-active")
self.set_timer(
self.ACTIVE_EFFECT_DURATION, partial(self.remove_class, "-active")
)
if self.active_effect_duration > 0:
self.add_class("-active")
self.set_timer(
self.active_effect_duration, partial(self.remove_class, "-active")
)

def action_press(self) -> None:
"""Activate a press of the button."""
Expand Down
11 changes: 5 additions & 6 deletions src/textual/widgets/_data_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -1116,9 +1116,13 @@ def move_cursor(
cursor_row = row
if column is not None:
cursor_column = column

destination = Coordinate(cursor_row, cursor_column)
self.cursor_coordinate = destination

# Scroll the cursor after refresh to ensure the virtual height
# (calculated in on_idle) has settled. If we tried to scroll before
# the virtual size has been set, then it might fail if we added a bunch
# of rows then tried to immediately move the cursor.
self.call_after_refresh(self._scroll_cursor_into_view, animate=animate)

def _highlight_coordinate(self, coordinate: Coordinate) -> None:
Expand Down Expand Up @@ -1231,7 +1235,6 @@ def _update_column_widths(self, updated_cells: set[CellKey]) -> None:
column = self.columns.get(column_key)
if column is None:
continue

console = self.app.console
label_width = measure(console, column.label, 1)
content_width = column.content_width
Expand Down Expand Up @@ -1289,8 +1292,6 @@ def _update_dimensions(self, new_rows: Iterable[RowKey]) -> None:
if row.auto_height:
auto_height_rows.append((row_index, row, cells_in_row))

self._clear_caches()

# If there are rows that need to have their height computed, render them correctly
# so that we can cache this rendering for later.
if auto_height_rows:
Expand Down Expand Up @@ -1616,11 +1617,9 @@ def remove_row(self, row_key: RowKey | str) -> None:
Raises:
RowDoesNotExist: If the row key does not exist.
"""

if row_key not in self._row_locations:
raise RowDoesNotExist(f"Row key {row_key!r} is not valid.")

self._new_rows.discard(row_key)
self._require_update_dimensions = True
self.check_idle()

Expand Down
39 changes: 28 additions & 11 deletions src/textual/widgets/_directory_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from pathlib import Path
from typing import TYPE_CHECKING, Callable, ClassVar, Iterable, Iterator

from ..await_complete import AwaitComplete

if TYPE_CHECKING:
from typing_extensions import Self

Expand Down Expand Up @@ -152,18 +154,26 @@ def __init__(
)
self.path = path

def _add_to_load_queue(self, node: TreeNode[DirEntry]) -> None:
def _add_to_load_queue(self, node: TreeNode[DirEntry]) -> AwaitComplete:
Copy link
Contributor

@rodrigogiraoserrao rodrigogiraoserrao Oct 24, 2023

Choose a reason for hiding this comment

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

The docstring is missing the return.
If I'm understanding this, the await complete we get is one that waits for the whole load queue to be processed, right?

I don't know how Queue works, but if you call join and then add more nodes to the load queue, won't the previously called join also wait for those nodes to be loaded?

Copy link
Member Author

@darrenburns darrenburns Oct 24, 2023

Choose a reason for hiding this comment

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

You might be on to something - I thought join() returned a Future. I'll investigate this more.
(I misread)

Yes, if you call join and then add more nodes to the queue, the join will wait until the queue is completely empty. An alternative would be to post a "marker" message on to the queue and wait for that to be processed, then stop waiting - maybe that'd be better for since some people might be polling their filesystem for changes faster than the queue is processed.

rodrigogiraoserrao marked this conversation as resolved.
Show resolved Hide resolved
"""Add the given node to the load queue.

The return value can optionally be awaited until the queue is empty.

Args:
node: The node to add to the load queue.

Returns:
An optionally awaitable object that can be awaited until the
load queue has finished processing.
"""
assert node.data is not None
if not node.data.loaded:
node.data.loaded = True
self._load_queue.put_nowait(node)

def reload(self) -> None:
return AwaitComplete(self._load_queue.join())

def reload(self) -> AwaitComplete:
darrenburns marked this conversation as resolved.
Show resolved Hide resolved
"""Reload the `DirectoryTree` contents."""
self.reset(str(self.path), DirEntry(self.PATH(self.path)))
# Orphan the old queue...
Expand All @@ -172,7 +182,8 @@ def reload(self) -> None:
self._loader()
# We have a fresh queue, we have a fresh loader, get the fresh root
# loading up.
self._add_to_load_queue(self.root)
queue_processed = self._add_to_load_queue(self.root)
return queue_processed

def clear_node(self, node: TreeNode[DirEntry]) -> Self:
"""Clear all nodes under the given node.
Expand Down Expand Up @@ -202,6 +213,7 @@ def reset_node(
"""Clear the subtree and reset the given node.

Args:
node: The node to reset.
label: The label for the node.
data: Optional data for the node.

Expand All @@ -213,16 +225,20 @@ def reset_node(
node.data = data
return self

def reload_node(self, node: TreeNode[DirEntry]) -> None:
def reload_node(self, node: TreeNode[DirEntry]) -> AwaitComplete:
darrenburns marked this conversation as resolved.
Show resolved Hide resolved
"""Reload the given node's contents.

The return value may be awaited to ensure the DirectoryTree has reached
a stable state and is no longer performing any node reloading (of this node
or any other nodes).

Args:
node: The node to reload.
"""
self.reset_node(
node, str(node.data.path.name), DirEntry(self.PATH(node.data.path))
)
self._add_to_load_queue(node)
return self._add_to_load_queue(node)

def validate_path(self, path: str | Path) -> Path:
"""Ensure that the path is of the `Path` type.
Expand All @@ -239,13 +255,13 @@ def validate_path(self, path: str | Path) -> Path:
"""
return self.PATH(path)

def watch_path(self) -> None:
async def watch_path(self) -> None:
"""Watch for changes to the `path` of the directory tree.

If the path is changed the directory tree will be repopulated using
the new value as the root.
"""
self.reload()
await self.reload()

def process_label(self, label: TextType) -> Text:
"""Process a str or Text into a label. Maybe overridden in a subclass to modify how labels are rendered.
Expand Down Expand Up @@ -421,16 +437,17 @@ async def _loader(self) -> None:
# the tree.
if content:
self._populate_node(node, content)
# Mark this iteration as done.
self._load_queue.task_done()
finally:
# Mark this iteration as done.
self._load_queue.task_done()

def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None:
async def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None:
event.stop()
dir_entry = event.node.data
if dir_entry is None:
return
if self._safe_is_dir(dir_entry.path):
self._add_to_load_queue(event.node)
await self._add_to_load_queue(event.node)
else:
self.post_message(self.FileSelected(event.node, dir_entry.path))

Expand Down
Loading
Loading