Skip to content
Open
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
1 change: 1 addition & 0 deletions changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Changelog
- [core] Fixed ``generate_sub_moved_events()`` corrupting paths when the directory name appears multiple times in the path. (`#1158 <https://github.com/gorakhargosh/watchdog/pull/1158>`__)
- Thanks to our beloved contributors: @BoboTiG, @tybug, @Corentin-pro, @kirkhansen, @JoachimCoenen, @blitztide
- [core] Call ``task_done()`` for the stop sentinel in ``dispatch_events()`` to prevent ``join()`` from hanging. (`#1159 <https://github.com/gorakhargosh/watchdog/pull/1159>`__)
- [tests] Fix ``test_renaming_top_level_directory`` failing on Windows when NTFS delays the last-write-time change notification of a watched directory. (`#1128 <https://github.com/gorakhargosh/watchdog/issues/1128>`__)

6.0.0
~~~~~
Expand Down
12 changes: 11 additions & 1 deletion tests/test_emitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,12 +450,22 @@ def test_renaming_top_level_directory(
mkdir(p("a", "b"))
with events_checker() as ec:
ec.add(DirCreatedEvent, "a/b")
if not platform.is_windows():
if platform.is_windows():
# NTFS updates the last write time of "a" lazily, so the
# notification may be delivered now, only when a later operation
# flushes it (see below), or not at all (#1128).
ec.add(DirModifiedEvent, "a", optional=True)
else:
ec.add(DirModifiedEvent, "a")

mv(p("a"), p("a2"))
with events_checker() as ec:
if platform.is_windows():
# The rename may flush the pending last-write-time change of "a"
# caused by mkdir(p("a", "b")) above. By the time the emitter
# handles the notification, "a" no longer exists on disk, so the
# event may be classified as a file event (#1128).
ec.add((DirModifiedEvent, FileModifiedEvent), "a", optional=True)
ec.add(DirMovedEvent, "a", dest_path="a2")
ec.add(DirMovedEvent, "a/b", dest_path="a2/b")
ec.add(DirModifiedEvent, "a2")
Expand Down
79 changes: 62 additions & 17 deletions tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,17 @@ class _ExpectedEvent:
everything else is optional (not checked if it is None).
"""

expected_class: type
expected_class: type | tuple[type, ...]
src_path: str | None = None
dest_path: str | None = None
# If true, the event may be absent from the received events.
optional: bool = False

@property
def class_name(self) -> str:
if isinstance(self.expected_class, tuple):
return "(" + ", ".join(cls.__name__ for cls in self.expected_class) + ")"
return self.expected_class.__name__


class _EventsChecker:
Expand Down Expand Up @@ -191,17 +199,29 @@ def _make_path(self, path: str | None) -> str | None:
path = os.path.join(self.tmp, *parts)
return os.path.normpath(path)

def add(self, expected_class: type, src_path: str | None = None, dest_path: str | None = None) -> None:
def add(
self,
expected_class: type | tuple[type, ...],
src_path: str | None = None,
dest_path: str | None = None,
*,
optional: bool = False,
) -> None:
"""Add details for an expected event. The `expected_class` argument
is required, everything else is optional. The order that events are
received does not matter but adding the same kind of event more than
once will require that it appears more than once.
is required, everything else is optional. It can be a single class or
a tuple of classes, in which case an event of any of those classes is
accepted. Adding the same kind of event more than once will require
that it appears more than once.

If `optional` is true, the event is allowed to be absent from the
received events. Use it for events whose delivery timing is not
deterministic on the platform.

Note that paths are provided as relative to `tmp` and using the forward
slash separator. They will be converted to absolute paths using `tmp`
and normalized. An empty path, i.e. "", is kept as-is.
"""
self.expected_events.append(_ExpectedEvent(expected_class, src_path, dest_path))
self.expected_events.append(_ExpectedEvent(expected_class, src_path, dest_path, optional))

def _match_event(self, event: FileSystemEvent, expected_event: _ExpectedEvent) -> bool:
if not isinstance(event, expected_event.expected_class):
Expand Down Expand Up @@ -229,13 +249,14 @@ def _check_events_without_order( self, found_events: list[FileSystemEvent]) -> N
del expected_events[i]
matched_events.append(event)
break
if expected_events:
missing_events = [e for e in expected_events if not e.optional]
if missing_events:
# Fail, we did not find some of the expected events.
if self._verbose:
self._debug("missing:")
for e in expected_events:
self._debug(" ", e.expected_class.__name__, e.src_path)
assert not expected_events, "some expected events not found"
for e in missing_events:
self._debug(" ", e.class_name, e.src_path)
assert not missing_events, "some expected events not found"
if not self._allow_extra: # noqa: SIM102
# Check that we did not receive extra events
if len(matched_events) < len(found_events):
Expand All @@ -245,12 +266,36 @@ def _check_events_without_order( self, found_events: list[FileSystemEvent]) -> N
msg = "received unexpected events"
raise AssertionError(msg)

def _check_events_with_order( self, found_events: list[FileSystemEvent]) -> None:
if not self._allow_extra:
# we need exactly the number of expected events
assert len(found_events) == len(self.expected_events), "wrong number of events"
for event, expected_event in zip(found_events, self.expected_events):
assert self._match_event(event, expected_event), "did not find expected event"
def _check_events_with_order(self, found_events: list[FileSystemEvent]) -> None:
# Check that the received events match the expected events, in order.
# Optional expected events are skipped when they did not occur.
expected_events = self.expected_events
index = 0
for event in found_events:
matched = False
while index < len(expected_events):
expected_event = expected_events[index]
if self._match_event(event, expected_event):
index += 1
matched = True
break
if expected_event.optional:
# The optional event did not occur, skip it.
self._debug("skipping optional:", expected_event)
index += 1
continue
break
if not matched and not self._allow_extra:
self._debug("unexpected:", event)
msg = "received unexpected events"
raise AssertionError(msg)
missing_events = [e for e in expected_events[index:] if not e.optional]
if missing_events:
if self._verbose:
self._debug("missing:")
for e in missing_events:
self._debug(" ", e.class_name, e.src_path)
assert not missing_events, "some expected events not found"

def check_events(self, timeout: float = 2) -> None:
"""Read events from the events queue (waiting for up to `timeout` for
Expand All @@ -260,7 +305,7 @@ def check_events(self, timeout: float = 2) -> None:
if self._verbose:
self._debug("expecting:")
for e in self.expected_events:
self._debug(" ", e.expected_class.__name__, repr(e.src_path))
self._debug(" ", e.class_name, repr(e.src_path))

found_events = []
while True:
Expand Down
Loading