From ba0a9e53ef3a363d0f646d160f0e7b900a705b52 Mon Sep 17 00:00:00 2001 From: Thomas Date: Sun, 13 Dec 2020 21:10:20 +0100 Subject: [PATCH 01/13] Add `is_coalesced` property to `NativeEvent` So that we can effectively decide if we need to perform additional system calls to figure out what really happened. --- src/watchdog_fsevents.c | 21 +++++++++++++++++++++ tests/test_fsevents.py | 28 +++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/watchdog_fsevents.c b/src/watchdog_fsevents.c index a8e8b9893..1f523b574 100644 --- a/src/watchdog_fsevents.c +++ b/src/watchdog_fsevents.c @@ -124,6 +124,26 @@ PyObject* NativeEventTypeID(PyObject* instance, void* closure) return PyLong_FromLong(self->id); } +PyObject* NativeEventTypeIsCoalesced(PyObject* instance, void* closure) +{ + UNUSED(closure); + NativeEventObject *self = (NativeEventObject*)instance; + + // if any of these bitmasks match then we have a coalesced event and need to do sys calls to figure out what happened + FSEventStreamEventFlags coalesced_masks[] = { + kFSEventStreamEventFlagItemCreated | kFSEventStreamEventFlagItemRemoved, + kFSEventStreamEventFlagItemCreated | kFSEventStreamEventFlagItemRenamed, + kFSEventStreamEventFlagItemRemoved | kFSEventStreamEventFlagItemRenamed, + }; + for (size_t i = 0; i < sizeof(coalesced_masks) / sizeof(FSEventStreamEventFlags); ++i) { + if ((self->flags & coalesced_masks[i]) == coalesced_masks[i]) { + Py_RETURN_TRUE; + } + } + + Py_RETURN_FALSE; +} + #define FLAG_PROPERTY(suffix, flag) \ PyObject* NativeEventType##suffix(PyObject* instance, void* closure) \ { \ @@ -175,6 +195,7 @@ static PyGetSetDef NativeEventProperties[] = { {"flags", NativeEventTypeFlags, NULL, "The raw mask of flags as returend by FSEvents", NULL}, {"path", NativeEventTypePath, NULL, "The path for which this event was generated", NULL}, {"event_id", NativeEventTypeID, NULL, "The id of the generated event", NULL}, + {"is_coalesced", NativeEventTypeIsCoalesced, NULL, "True if multiple ambiguous changes to the monitored path happened", NULL}, {"must_scan_subdirs", NativeEventTypeIsMustScanSubDirs, NULL, "True if application must rescan all subdirectories", NULL}, {"is_user_dropped", NativeEventTypeIsUserDropped, NULL, "True if a failure during event buffering occured", NULL}, {"is_kernel_dropped", NativeEventTypeIsKernelDropped, NULL, "True if a failure during event buffering occured", NULL}, diff --git a/tests/test_fsevents.py b/tests/test_fsevents.py index bdbf35ad9..eb45141da 100644 --- a/tests/test_fsevents.py +++ b/tests/test_fsevents.py @@ -13,6 +13,7 @@ from os import mkdir, rmdir from queue import Queue +import _watchdog_fsevents as _fsevents from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer from watchdog.observers.api import ObservedWatch @@ -32,10 +33,13 @@ def setup_function(function): def teardown_function(function): - emitter.stop() - emitter.join(5) + try: + emitter.stop() + emitter.join(5) + assert not emitter.is_alive() + except NameError: + pass # `name 'emitter' is not defined` unless we call `start_watching` rm(p(""), recursive=True) - assert not emitter.is_alive() def start_watching(path=None, use_full_emitter=False): @@ -57,6 +61,24 @@ def observer(): pass +@pytest.mark.parametrize('event,expectation', [ + # invalid flags + (_fsevents.NativeEvent('', 0, 0), False), + # renamed + (_fsevents.NativeEvent('', 0x00000800, 0), False), + # renamed, removed + (_fsevents.NativeEvent('', 0x00000800 | 0x00000200, 0), True), + # renamed, removed, created + (_fsevents.NativeEvent('', 0x00000800 | 0x00000200 | 0x00000100, 0), True), + # renamed, removed, created, itemfindermod + (_fsevents.NativeEvent('', 0x00000800 | 0x00000200 | 0x00000100 | 0x00002000, 0), True), + # xattr, removed, modified, itemfindermod + (_fsevents.NativeEvent('', 0x00008000 | 0x00000200 | 0x00001000 | 0x00002000, 0), False), +]) +def test_coalesced_event_check(event, expectation): + assert event.is_coalesced == expectation + + def test_remove_watch_twice(): """ ValueError: PyCapsule_GetPointer called with invalid PyCapsule object From cd9433082ac56ed45c7170b9c8de62a87bf40ecc Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 14 Dec 2020 21:23:59 +0100 Subject: [PATCH 02/13] Replace `NativeEvent._event_type` with `repr()` support It's more pythonic, and the `_event_type` implementation wasn't quite usable anyway. NB: the representation is not truly copy/paste python code if there is a double quote inside event.path, but that should be a rare case so we don't add the expensive special case handling there. --- src/watchdog_fsevents.c | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/src/watchdog_fsevents.c b/src/watchdog_fsevents.c index db44307ee..7c64cda96 100644 --- a/src/watchdog_fsevents.c +++ b/src/watchdog_fsevents.c @@ -113,20 +113,15 @@ typedef struct { FSEventStreamEventId id; } NativeEventObject; -PyObject* NativeEventTypeString(PyObject* instance, void* closure) -{ - UNUSED(closure); +PyObject* NativeEventRepr(PyObject* instance) { NativeEventObject *self = (NativeEventObject*)instance; - if (self->flags & kFSEventStreamEventFlagItemCreated) - return PyUnicode_FromString("Created"); - if (self->flags & kFSEventStreamEventFlagItemRemoved) - return PyUnicode_FromString("Removed"); - if (self->flags & kFSEventStreamEventFlagItemRenamed) - return PyUnicode_FromString("Renamed"); - if (self->flags & kFSEventStreamEventFlagItemModified) - return PyUnicode_FromString("Modified"); - - return PyUnicode_FromString("Unknown"); + + return PyUnicode_FromFormat( + "NativeEvent(path=\"%s\", flags=%x, id=%llu)", + self->path, + self->flags, + self->id + ); } PyObject* NativeEventTypeFlags(PyObject* instance, void* closure) @@ -217,7 +212,6 @@ static int NativeEventInit(NativeEventObject *self, PyObject *args, PyObject *kw } static PyGetSetDef NativeEventProperties[] = { - {"_event_type", NativeEventTypeString, NULL, "Textual representation of the native event that occurred", NULL}, {"flags", NativeEventTypeFlags, NULL, "The raw mask of flags as returend by FSEvents", NULL}, {"path", NativeEventTypePath, NULL, "The path for which this event was generated", NULL}, {"event_id", NativeEventTypeID, NULL, "The id of the generated event", NULL}, @@ -259,6 +253,7 @@ static PyTypeObject NativeEventType = { .tp_new = PyType_GenericNew, .tp_getset = NativeEventProperties, .tp_init = (initproc) NativeEventInit, + .tp_repr = (reprfunc) NativeEventRepr, }; From 9515854046fd687e4f62237ee5436a9997a0d6ff Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 15 Dec 2020 17:56:40 +0100 Subject: [PATCH 03/13] Allow running tests with debugger attached Some Python debuggers create additional threads, so we shouldn't assume that there is only one. --- tests/conftest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 91ad80b06..9dd9fabcc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,9 +28,10 @@ def no_thread_leaks(): Fail on thread leak. We do not use pytest-threadleak because it is not reliable. """ + old_thread_count = threading.active_count() yield gc.collect() # Clear the stuff from other function-level fixtures - assert threading.active_count() == 1 # Only the main thread + assert threading.active_count() == old_thread_count # Only previously existing threads @pytest.fixture(autouse=True) From 1bc57301783c4effd114f9cd7f05aee28583d64d Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 16 Dec 2020 16:06:21 +0100 Subject: [PATCH 04/13] Request notifications for watched root --- src/watchdog_fsevents.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/watchdog_fsevents.c b/src/watchdog_fsevents.c index 7c64cda96..a945edbc2 100644 --- a/src/watchdog_fsevents.c +++ b/src/watchdog_fsevents.c @@ -509,7 +509,7 @@ watchdog_FSEventStreamCreate(StreamCallbackInfo *stream_callback_info_ref, paths, kFSEventStreamEventIdSinceNow, stream_latency, - kFSEventStreamCreateFlagNoDefer | kFSEventStreamCreateFlagFileEvents); + kFSEventStreamCreateFlagNoDefer | kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagWatchRoot); CFRelease(paths); return stream_ref; } From 3ab47db83d769e46131493e3225f11f86120c0d2 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 17 Dec 2020 20:49:07 +0100 Subject: [PATCH 05/13] Expect events on macOS instead of using `time.sleep()` It might be even better to check for the emitter class, as opposed to platform --- tests/test_emitter.py | 162 +++++++++++++++++++++++++++--------------- 1 file changed, 104 insertions(+), 58 deletions(-) diff --git a/tests/test_emitter.py b/tests/test_emitter.py index 082ec3832..8b3a38b8b 100644 --- a/tests/test_emitter.py +++ b/tests/test_emitter.py @@ -41,7 +41,6 @@ InotifyFullEmitter, ) elif platform.is_darwin(): - pytestmark = pytest.mark.skip("FIXME: issue #546.") from watchdog.observers.fsevents import FSEventsEmitter as Emitter elif platform.is_windows(): from watchdog.observers.read_directory_changes import ( @@ -64,29 +63,65 @@ def setup_teardown(tmpdir): yield - emitter.stop() + try: + emitter.stop() + except OSError: + # watch was already stopped, e.g. in `test_delete_self` + pass emitter.join(5) assert not emitter.is_alive() def start_watching(path=None, use_full_emitter=False, recursive=True): - path = p('') if path is None else path + # todo: check if other platforms expect the trailing slash (e.g. `p('')`) + path = p() if path is None else path global emitter if platform.is_linux() and use_full_emitter: emitter = InotifyFullEmitter(event_queue, ObservedWatch(path, recursive=recursive)) else: emitter = Emitter(event_queue, ObservedWatch(path, recursive=recursive)) - if platform.is_darwin(): - # FSEvents will report old events (like create for mkdtemp in test - # setup. Waiting for a considerable time seems to 'flush' the events. - time.sleep(10) emitter.start() + if platform.is_darwin(): + # FSEvents _may_ report the event for the creation of `tmpdir`, + # however, we're racing with fseventd there - if other filesystem + # events happened _after_ `tmpdir` was created, but _before_ we + # created the emitter then we won't get this event. + # But if we get it, then we'll also get the `DirModifiedEvent` + # for `path` and thus have to receive that. + try: + expect_event(DirCreatedEvent(path)) + except Empty: + if os.path.exists(path): + pass + else: + expect_event(DirModifiedEvent(os.path.dirname(path))) + def rerun_filter(exc, *args): time.sleep(5) - return issubclass(exc[0], Empty) and platform.is_windows() + if issubclass(exc[0], Empty) and platform.is_windows(): + return True + + # on macOS with fsevents we may sometimes miss the creation event for tmpdir, + # and that will then trigger an assertion failure. + if issubclass(exc[0], (Empty, AssertionError)) and platform.is_darwin(): + return True + return False + + +def expect_event(expected_event, timeout=30.0): + """ Utility function to wait up to `timeout` seconds for an `event_type` for `path` to show up in the queue. + + Provides some robustness for the otherwise flaky nature of asynchronous notifications. + NB: specifying a timeout of less than 30 seconds doesn't play nicely on macOS + """ + try: + event = event_queue.get(timeout=timeout)[0] + assert event == expected_event + except Empty: + raise @pytest.mark.flaky(max_runs=5, min_passes=1, rerun_filter=rerun_filter) @@ -94,14 +129,10 @@ def test_create(): start_watching() open(p('a'), 'a').close() - event = event_queue.get(timeout=5)[0] - assert event.src_path == p('a') - assert isinstance(event, FileCreatedEvent) + expect_event(FileCreatedEvent(p('a'))) if not platform.is_windows(): - event = event_queue.get(timeout=5)[0] - assert os.path.normpath(event.src_path) == os.path.normpath(p('')) - assert isinstance(event, DirModifiedEvent) + expect_event(DirModifiedEvent(p())) @pytest.mark.flaky(max_runs=5, min_passes=1, rerun_filter=rerun_filter) @@ -127,27 +158,43 @@ def test_create_wrong_encoding(): def test_delete(): touch(p('a')) start_watching() + + if platform.is_darwin(): + # anticipate the initial events + expect_event(FileCreatedEvent(p('a'))) + expect_event(DirModifiedEvent(p())) + expect_event(FileModifiedEvent(p('a'))) + rm(p('a')) - event = event_queue.get(timeout=5)[0] - assert event.src_path == p('a') - assert isinstance(event, FileDeletedEvent) + # FIXME fseventsd fools the emitter by sending 0x10700 which is a file that is created, modified, and deleted + if platform.is_darwin(): + expect_event(FileModifiedEvent(p('a'))) + + expect_event(FileDeletedEvent(p('a'))) if not platform.is_windows(): - event = event_queue.get(timeout=5)[0] - assert os.path.normpath(event.src_path) == os.path.normpath(p('')) - assert isinstance(event, DirModifiedEvent) + expect_event(DirModifiedEvent(p())) @pytest.mark.flaky(max_runs=5, min_passes=1, rerun_filter=rerun_filter) def test_modify(): - touch(p('a')) start_watching() touch(p('a')) - event = event_queue.get(timeout=5)[0] - assert event.src_path == p('a') - assert isinstance(event, FileModifiedEvent) + if platform.is_darwin(): + expect_event(FileCreatedEvent(p('a'))) + expect_event(DirModifiedEvent(p())) + expect_event(FileModifiedEvent(p('a'))) + + touch(p('a')) + + # FIXME we're not dealing well with coalesced modifications + if platform.is_darwin(): + expect_event(FileCreatedEvent(p('a'))) + expect_event(DirModifiedEvent(p())) + + expect_event(FileModifiedEvent(p('a'))) @pytest.mark.flaky(max_runs=5, min_passes=1, rerun_filter=rerun_filter) @@ -156,13 +203,19 @@ def test_move(): mkdir(p('dir2')) touch(p('dir1', 'a')) start_watching() + if platform.is_darwin(): + expect_event(DirCreatedEvent(p('dir1'))) + expect_event(DirModifiedEvent(p())) + expect_event(DirCreatedEvent(p('dir2'))) + expect_event(DirModifiedEvent(p())) + expect_event(FileCreatedEvent(p('dir1', 'a'))) + expect_event(DirModifiedEvent(p('dir1'))) + expect_event(FileModifiedEvent(p('dir1', 'a'))) + mv(p('dir1', 'a'), p('dir2', 'b')) if not platform.is_windows(): - event = event_queue.get(timeout=5)[0] - assert event.src_path == p('dir1', 'a') - assert event.dest_path == p('dir2', 'b') - assert isinstance(event, FileMovedEvent) + expect_event(FileMovedEvent(p('dir1', 'a'), p('dir2', 'b'))) else: event = event_queue.get(timeout=5)[0] assert event.src_path == p('dir1', 'a') @@ -187,16 +240,13 @@ def test_move_to(): mkdir(p('dir2')) touch(p('dir1', 'a')) start_watching(p('dir2')) + mv(p('dir1', 'a'), p('dir2', 'b')) - event = event_queue.get(timeout=5)[0] - assert event.src_path == p('dir2', 'b') - assert isinstance(event, FileCreatedEvent) + expect_event(FileCreatedEvent(p('dir2', 'b'))) if not platform.is_windows(): - event = event_queue.get(timeout=5)[0] - assert event.src_path == p('dir2') - assert isinstance(event, DirModifiedEvent) + expect_event(DirModifiedEvent(p('dir2'))) @pytest.mark.skipif(not platform.is_linux(), reason="InotifyFullEmitter only supported in Linux") @@ -219,16 +269,18 @@ def test_move_from(): mkdir(p('dir2')) touch(p('dir1', 'a')) start_watching(p('dir1')) + + if platform.is_darwin(): + expect_event(FileCreatedEvent(p('dir1', 'a'))) + expect_event(DirModifiedEvent(p('dir1'))) + expect_event(FileModifiedEvent(p('dir1', 'a'))) + mv(p('dir1', 'a'), p('dir2', 'b')) - event = event_queue.get(timeout=5)[0] - assert isinstance(event, FileDeletedEvent) - assert event.src_path == p('dir1', 'a') + expect_event(FileDeletedEvent(p('dir1', 'a'))) if not platform.is_windows(): - event = event_queue.get(timeout=5)[0] - assert event.src_path == p('dir1') - assert isinstance(event, DirModifiedEvent) + expect_event(DirModifiedEvent(p('dir1'))) @pytest.mark.skipif(not platform.is_linux(), reason="InotifyFullEmitter only supported in Linux") @@ -254,27 +306,22 @@ def test_separate_consecutive_moves(): mv(p('dir1', 'a'), p('c')) mv(p('b'), p('dir1', 'd')) - dir_modif = (DirModifiedEvent, p('dir1')) - a_deleted = (FileDeletedEvent, p('dir1', 'a')) - d_created = (FileCreatedEvent, p('dir1', 'd')) + dir_modif = DirModifiedEvent(p('dir1')) + a_deleted = FileDeletedEvent(p('dir1', 'a')) + d_created = FileCreatedEvent(p('dir1', 'd')) - expected = [a_deleted, dir_modif, d_created, dir_modif] + expected_events = [a_deleted, dir_modif, d_created, dir_modif] if platform.is_windows(): - expected = [a_deleted, d_created] + expected_events = [a_deleted, d_created] if platform.is_bsd(): # Due to the way kqueue works, we can't really order # 'Created' and 'Deleted' events in time, so creation queues first - expected = [d_created, a_deleted, dir_modif, dir_modif] - - def _step(expected_step): - event = event_queue.get(timeout=5)[0] - assert event.src_path == expected_step[1] - assert isinstance(event, expected_step[0]) + expected_events = [d_created, a_deleted, dir_modif, dir_modif] - for expected_step in expected: - _step(expected_step) + for expected_event in expected_events: + expect_event(expected_event) @pytest.mark.flaky(max_runs=5, min_passes=1, rerun_filter=rerun_filter) @@ -282,11 +329,9 @@ def test_delete_self(): mkdir(p('dir1')) start_watching(p('dir1')) rm(p('dir1'), True) - - if platform.is_darwin(): - event = event_queue.get(timeout=5)[0] - assert event.src_path == p('dir1') - assert isinstance(event, FileDeletedEvent) + expect_event(FileDeletedEvent(p('dir1'))) + emitter.join(5) + assert not emitter.is_alive() @pytest.mark.skipif(platform.is_windows() or platform.is_bsd(), @@ -359,6 +404,7 @@ def test_recursive_on(): @pytest.mark.flaky(max_runs=5, min_passes=1, rerun_filter=rerun_filter) +@pytest.mark.skipif(platform.is_darwin(), reason="macOS watches are always recursive") def test_recursive_off(): mkdir(p('dir1')) start_watching(recursive=False) From 3c35a1dacbd6adff743b38dcc2df9954fe223b3f Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 17 Dec 2020 20:51:55 +0100 Subject: [PATCH 06/13] Add exception handling to FSEventsEmitter Reduce the amount of 'silent breakage' --- src/watchdog/observers/fsevents.py | 140 ++++++++++++++++++----------- 1 file changed, 90 insertions(+), 50 deletions(-) diff --git a/src/watchdog/observers/fsevents.py b/src/watchdog/observers/fsevents.py index 1226ca4a9..a0b2c3ffb 100644 --- a/src/watchdog/observers/fsevents.py +++ b/src/watchdog/observers/fsevents.py @@ -23,6 +23,7 @@ :platforms: Mac OS X """ +import logging import os import threading import unicodedata @@ -46,6 +47,9 @@ DEFAULT_OBSERVER_TIMEOUT ) +logger = logging.getLogger('fsevents') +logger.addHandler(logging.NullHandler()) + class FSEventsEmitter(EventEmitter): @@ -74,66 +78,102 @@ def on_thread_stop(self): _fsevents.stop(self) self._watch = None - def queue_events(self, timeout): - with self._lock: - events = self.native_events - i = 0 - while i < len(events): - event = events[i] - src_path = self._encode_path(event.path) - - # For some reason the create and remove flags are sometimes also - # set for rename and modify type events, so let those take - # precedence. - if event.is_renamed: - # Internal moves appears to always be consecutive in the same - # buffer and have IDs differ by exactly one (while others - # don't) making it possible to pair up the two events coming - # from a singe move operation. (None of this is documented!) - # Otherwise, guess whether file was moved in or out. - # TODO: handle id wrapping - if (i + 1 < len(events) and events[i + 1].is_renamed - and events[i + 1].event_id == event.event_id + 1): - cls = DirMovedEvent if event.is_directory else FileMovedEvent - dst_path = self._encode_path(events[i + 1].path) - self.queue_event(cls(src_path, dst_path)) - self.queue_event(DirModifiedEvent(os.path.dirname(src_path))) - self.queue_event(DirModifiedEvent(os.path.dirname(dst_path))) - i += 1 - elif os.path.exists(event.path): - cls = DirCreatedEvent if event.is_directory else FileCreatedEvent - self.queue_event(cls(src_path)) - self.queue_event(DirModifiedEvent(os.path.dirname(src_path))) - else: - cls = DirDeletedEvent if event.is_directory else FileDeletedEvent - self.queue_event(cls(src_path)) - self.queue_event(DirModifiedEvent(os.path.dirname(src_path))) - # TODO: generate events for tree - - elif event.is_modified or event.is_inode_meta_mod or event.is_xattr_mod: - cls = DirModifiedEvent if event.is_directory else FileModifiedEvent + def queue_event(self, event): + logger.info("queue_event %s", event) + EventEmitter.queue_event(self, event) + + def queue_events(self, timeout, events): + i = 0 + while i < len(events): + event = events[i] + logger.info(event) + src_path = self._encode_path(event.path) + + """ + FIXME: It is not enough to just de-duplicate the events based on + whether they are coalesced or not. We must also take into + account old and new state. As such we need to track all + events that occurred in order to make a correct decision + about which events should be generated. + It is worth noting that `DirSnapshot` is _not_ the right + way of doing it since that traverses _everything_. + """ + + # For some reason the create and remove flags are sometimes also + # set for rename and modify type events, so let those take + # precedence. + if event.is_renamed: + # Internal moves appears to always be consecutive in the same + # buffer and have IDs differ by exactly one (while others + # don't) making it possible to pair up the two events coming + # from a singe move operation. (None of this is documented!) + # Otherwise, guess whether file was moved in or out. + # TODO: handle id wrapping + if (i + 1 < len(events) and events[i + 1].is_renamed + and events[i + 1].event_id == event.event_id + 1): + logger.info("Next event for rename is %s", events[i + 1]) + cls = DirMovedEvent if event.is_directory else FileMovedEvent + dst_path = self._encode_path(events[i + 1].path) + self.queue_event(cls(src_path, dst_path)) + self.queue_event(DirModifiedEvent(os.path.dirname(src_path))) + self.queue_event(DirModifiedEvent(os.path.dirname(dst_path))) + i += 1 + elif os.path.exists(event.path): + cls = DirCreatedEvent if event.is_directory else FileCreatedEvent self.queue_event(cls(src_path)) + self.queue_event(DirModifiedEvent(os.path.dirname(src_path))) + else: + cls = DirDeletedEvent if event.is_directory else FileDeletedEvent + self.queue_event(cls(src_path)) + self.queue_event(DirModifiedEvent(os.path.dirname(src_path))) + # TODO: generate events for tree - elif event.is_created: - cls = DirCreatedEvent if event.is_directory else FileCreatedEvent + if event.is_created: + cls = DirCreatedEvent if event.is_directory else FileCreatedEvent + if not event.is_coalesced or ( + event.is_coalesced and not event.is_renamed and os.path.exists(event.path) + ): self.queue_event(cls(src_path)) self.queue_event(DirModifiedEvent(os.path.dirname(src_path))) - elif event.is_removed: - cls = DirDeletedEvent if event.is_directory else FileDeletedEvent + if event.is_modified or event.is_inode_meta_mod or event.is_xattr_mod: + # NB: in the scenario of touch(file) -> rm(file) we can trigger this twice + cls = DirModifiedEvent if event.is_directory else FileModifiedEvent + self.queue_event(cls(src_path)) + + if event.is_removed: + cls = DirDeletedEvent if event.is_directory else FileDeletedEvent + if not event.is_coalesced or (event.is_coalesced and not os.path.exists(event.path)): self.queue_event(cls(src_path)) self.queue_event(DirModifiedEvent(os.path.dirname(src_path))) - i += 1 + + if src_path == self.watch.path: + # this should not really occur, instead we expect + # is_root_changed to be set + logger.info("Stopping because root path was removed") + self.stop() + + if event.is_root_changed: + # This will be set if root or any of its parents is renamed or + # deleted. + # TODO: find out new path and generate DirMovedEvent? + self.queue_event(DirDeletedEvent(self.watch.path)) + logger.info("Stopping because root path was changed") + self.stop() + + i += 1 def run(self): try: def callback(pathnames, flags, ids, emitter=self): - with emitter._lock: - emitter.native_events = [ - _fsevents.NativeEvent(event_path, event_flags, event_id) - for event_path, event_flags, event_id in zip(pathnames, flags, ids) - ] - emitter.queue_events(emitter.timeout) + try: + with emitter._lock: + emitter.queue_events(emitter.timeout, [ + _fsevents.NativeEvent(event_path, event_flags, event_id) + for event_path, event_flags, event_id in zip(pathnames, flags, ids) + ]) + except Exception: + logger.exception("Unhandled exception in fsevents callback") # for pathname, flag in zip(pathnames, flags): # if emitter.watch.is_recursive: # and pathname != emitter.watch.path: @@ -162,7 +202,7 @@ def callback(pathnames, flags, ids, emitter=self): self.pathnames) _fsevents.read_events(self) except Exception: - pass + logger.exception("Unhandled exception in FSEventsEmitter") def _encode_path(self, path): """Encode path only if bytes were passed to this emitter. """ From 164debdd3a6f43fee67b6ff30ae13e482a1d6f1e Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 18 Dec 2020 09:48:26 +0100 Subject: [PATCH 07/13] Use sentinel event when setting up tests on macOS So that we can avoid a race between test setup and fseventsd --- tests/test_emitter.py | 51 ++++++++++++++++++------------------------- 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/tests/test_emitter.py b/tests/test_emitter.py index 8b3a38b8b..1c8b7c514 100644 --- a/tests/test_emitter.py +++ b/tests/test_emitter.py @@ -88,15 +88,29 @@ def start_watching(path=None, use_full_emitter=False, recursive=True): # however, we're racing with fseventd there - if other filesystem # events happened _after_ `tmpdir` was created, but _before_ we # created the emitter then we won't get this event. - # But if we get it, then we'll also get the `DirModifiedEvent` - # for `path` and thus have to receive that. - try: - expect_event(DirCreatedEvent(path)) - except Empty: - if os.path.exists(path): + # As such, let's create a sentinel event that tells us that we are + # good to go. + sentinel_file = os.path.join(path, '.sentinel') + touch(sentinel_file) + sentinel_events = [ + FileCreatedEvent(sentinel_file), + DirModifiedEvent(path), + FileModifiedEvent(sentinel_file) + ] + next_sentinel_event = sentinel_events.pop(0) + now = time.monotonic() + while time.monotonic() <= now + 30.0: + try: + event = event_queue.get(timeout=0.5)[0] + if event == next_sentinel_event: + if not sentinel_events: + break + next_sentinel_event = sentinel_events.pop(0) + except Empty: pass + time.sleep(0.1) else: - expect_event(DirModifiedEvent(os.path.dirname(path))) + assert False, "Sentinel event never arrived!" def rerun_filter(exc, *args): @@ -104,10 +118,6 @@ def rerun_filter(exc, *args): if issubclass(exc[0], Empty) and platform.is_windows(): return True - # on macOS with fsevents we may sometimes miss the creation event for tmpdir, - # and that will then trigger an assertion failure. - if issubclass(exc[0], (Empty, AssertionError)) and platform.is_darwin(): - return True return False @@ -159,12 +169,6 @@ def test_delete(): touch(p('a')) start_watching() - if platform.is_darwin(): - # anticipate the initial events - expect_event(FileCreatedEvent(p('a'))) - expect_event(DirModifiedEvent(p())) - expect_event(FileModifiedEvent(p('a'))) - rm(p('a')) # FIXME fseventsd fools the emitter by sending 0x10700 which is a file that is created, modified, and deleted @@ -203,14 +207,6 @@ def test_move(): mkdir(p('dir2')) touch(p('dir1', 'a')) start_watching() - if platform.is_darwin(): - expect_event(DirCreatedEvent(p('dir1'))) - expect_event(DirModifiedEvent(p())) - expect_event(DirCreatedEvent(p('dir2'))) - expect_event(DirModifiedEvent(p())) - expect_event(FileCreatedEvent(p('dir1', 'a'))) - expect_event(DirModifiedEvent(p('dir1'))) - expect_event(FileModifiedEvent(p('dir1', 'a'))) mv(p('dir1', 'a'), p('dir2', 'b')) @@ -270,11 +266,6 @@ def test_move_from(): touch(p('dir1', 'a')) start_watching(p('dir1')) - if platform.is_darwin(): - expect_event(FileCreatedEvent(p('dir1', 'a'))) - expect_event(DirModifiedEvent(p('dir1'))) - expect_event(FileModifiedEvent(p('dir1', 'a'))) - mv(p('dir1', 'a'), p('dir2', 'b')) expect_event(FileDeletedEvent(p('dir1', 'a'))) From a8877ca3dd356e0baa74753fb5a9b07a17280d42 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 6 Jan 2021 16:09:07 +0100 Subject: [PATCH 08/13] Improve handling of coalesced events --- src/watchdog/observers/fsevents.py | 30 +++++++++++++++++++++--------- tests/test_emitter.py | 29 ++++++++--------------------- 2 files changed, 29 insertions(+), 30 deletions(-) diff --git a/src/watchdog/observers/fsevents.py b/src/watchdog/observers/fsevents.py index a0b2c3ffb..35ddd4644 100644 --- a/src/watchdog/observers/fsevents.py +++ b/src/watchdog/observers/fsevents.py @@ -48,7 +48,6 @@ ) logger = logging.getLogger('fsevents') -logger.addHandler(logging.NullHandler()) class FSEventsEmitter(EventEmitter): @@ -71,12 +70,15 @@ class FSEventsEmitter(EventEmitter): def __init__(self, event_queue, watch, timeout=DEFAULT_EMITTER_TIMEOUT): EventEmitter.__init__(self, event_queue, watch, timeout) self._lock = threading.Lock() + # a dictionary of event.path -> posix.stat_result + self.filesystem_view = {} def on_thread_stop(self): if self.watch: _fsevents.remove_watch(self.watch) _fsevents.stop(self) self._watch = None + self.filesystem_view.clear() def queue_event(self, event): logger.info("queue_event %s", event) @@ -93,10 +95,8 @@ def queue_events(self, timeout, events): FIXME: It is not enough to just de-duplicate the events based on whether they are coalesced or not. We must also take into account old and new state. As such we need to track all - events that occurred in order to make a correct decision + events that occurred so that we can make a correct decision about which events should be generated. - It is worth noting that `DirSnapshot` is _not_ the right - way of doing it since that traverses _everything_. """ # For some reason the create and remove flags are sometimes also @@ -133,19 +133,31 @@ def queue_events(self, timeout, events): if not event.is_coalesced or ( event.is_coalesced and not event.is_renamed and os.path.exists(event.path) ): - self.queue_event(cls(src_path)) - self.queue_event(DirModifiedEvent(os.path.dirname(src_path))) + if src_path not in self.filesystem_view: + self.filesystem_view[src_path] = os.stat(src_path) + self.queue_event(cls(src_path)) + self.queue_event(DirModifiedEvent(os.path.dirname(src_path))) - if event.is_modified or event.is_inode_meta_mod or event.is_xattr_mod: - # NB: in the scenario of touch(file) -> rm(file) we can trigger this twice + if event.is_modified and not event.is_coalesced and os.path.exists(src_path): cls = DirModifiedEvent if event.is_directory else FileModifiedEvent - self.queue_event(cls(src_path)) + new = os.stat(src_path) + old = self.filesystem_view.get(src_path, None) + if new != old: + self.queue_event(cls(src_path)) + self.filesystem_view[src_path] = new + + if event.is_inode_meta_mod or event.is_xattr_mod: + if os.path.exists(src_path) and not event.is_coalesced: + # NB: in the scenario of touch(file) -> rm(file) we can trigger this twice + cls = DirModifiedEvent if event.is_directory else FileModifiedEvent + self.queue_event(cls(src_path)) if event.is_removed: cls = DirDeletedEvent if event.is_directory else FileDeletedEvent if not event.is_coalesced or (event.is_coalesced and not os.path.exists(event.path)): self.queue_event(cls(src_path)) self.queue_event(DirModifiedEvent(os.path.dirname(src_path))) + self.filesystem_view.pop(src_path, None) if src_path == self.watch.path: # this should not really occur, instead we expect diff --git a/tests/test_emitter.py b/tests/test_emitter.py index 10ed7588e..d137e132e 100644 --- a/tests/test_emitter.py +++ b/tests/test_emitter.py @@ -90,7 +90,7 @@ def start_watching(path=None, use_full_emitter=False, recursive=True): # created the emitter then we won't get this event. # As such, let's create a sentinel event that tells us that we are # good to go. - sentinel_file = os.path.join(path, '.sentinel') + sentinel_file = os.path.join(path, '.sentinel' if isinstance(path, str) else '.sentinel'.encode()) touch(sentinel_file) sentinel_events = [ FileCreatedEvent(sentinel_file), @@ -171,10 +171,6 @@ def test_delete(): rm(p('a')) - # FIXME fseventsd fools the emitter by sending 0x10700 which is a file that is created, modified, and deleted - if platform.is_darwin(): - expect_event(FileModifiedEvent(p('a'))) - expect_event(FileDeletedEvent(p('a'))) if not platform.is_windows(): @@ -183,21 +179,11 @@ def test_delete(): @pytest.mark.flaky(max_runs=5, min_passes=1, rerun_filter=rerun_filter) def test_modify(): - start_watching() touch(p('a')) - - if platform.is_darwin(): - expect_event(FileCreatedEvent(p('a'))) - expect_event(DirModifiedEvent(p())) - expect_event(FileModifiedEvent(p('a'))) + start_watching() touch(p('a')) - # FIXME we're not dealing well with coalesced modifications - if platform.is_darwin(): - expect_event(FileCreatedEvent(p('a'))) - expect_event(DirModifiedEvent(p())) - expect_event(FileModifiedEvent(p('a'))) @@ -306,7 +292,7 @@ def test_separate_consecutive_moves(): if platform.is_windows(): expected_events = [a_deleted, d_created] - if platform.is_bsd(): + if platform.is_linux(): # Due to the way kqueue works, we can't really order # 'Created' and 'Deleted' events in time, so creation queues first expected_events = [d_created, a_deleted, dir_modif, dir_modif] @@ -320,13 +306,14 @@ def test_delete_self(): mkdir(p('dir1')) start_watching(p('dir1')) rm(p('dir1'), True) - expect_event(FileDeletedEvent(p('dir1'))) + expect_event(DirDeletedEvent(p('dir1'))) emitter.join(5) assert not emitter.is_alive() @pytest.mark.skipif(platform.is_windows() or platform.is_bsd(), reason="Windows|BSD create another set of events for this test") +@pytest.mark.skipif(platform.is_darwin(), reason="FSEvents coalesced events make it impossible to assert this way") def test_fast_subdirectory_creation_deletion(): root_dir = p('dir1') sub_dir = p('dir1', 'subdir1') @@ -357,7 +344,7 @@ def test_fast_subdirectory_creation_deletion(): @pytest.mark.flaky(max_runs=5, min_passes=1, rerun_filter=rerun_filter) def test_passing_unicode_should_give_unicode(): - start_watching(str(p(""))) + start_watching(str(p())) touch(p('a')) event = event_queue.get(timeout=5)[0] assert isinstance(event.src_path, str) @@ -367,7 +354,7 @@ def test_passing_unicode_should_give_unicode(): reason="Windows ReadDirectoryChangesW supports only" " unicode for paths.") def test_passing_bytes_should_give_bytes(): - start_watching(p('').encode()) + start_watching(p().encode()) touch(p('a')) event = event_queue.get(timeout=5)[0] assert isinstance(event.src_path, bytes) @@ -523,7 +510,7 @@ def test_renaming_top_level_directory_on_windows(): def test_move_nested_subdirectories(): mkdir(p('dir1/dir2/dir3'), parents=True) touch(p('dir1/dir2/dir3', 'a')) - start_watching(p('')) + start_watching() mv(p('dir1/dir2'), p('dir2')) event = event_queue.get(timeout=5)[0] From d24e0a34776c1f98024be5dcbbb8523616d25df6 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 7 Jan 2021 14:45:06 +0100 Subject: [PATCH 09/13] Revert accidental platform check change --- tests/test_emitter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_emitter.py b/tests/test_emitter.py index d137e132e..f71748da3 100644 --- a/tests/test_emitter.py +++ b/tests/test_emitter.py @@ -292,7 +292,7 @@ def test_separate_consecutive_moves(): if platform.is_windows(): expected_events = [a_deleted, d_created] - if platform.is_linux(): + if platform.is_bsd(): # Due to the way kqueue works, we can't really order # 'Created' and 'Deleted' events in time, so creation queues first expected_events = [d_created, a_deleted, dir_modif, dir_modif] From 5f77d79fb86de8e7c357752af8fcc7f47c26c10e Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 7 Jan 2021 15:26:30 +0100 Subject: [PATCH 10/13] Fix renaming_top_level_directory test on macOS --- tests/test_emitter.py | 36 ++++++++++-------------------------- 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/tests/test_emitter.py b/tests/test_emitter.py index f71748da3..cd68e4549 100644 --- a/tests/test_emitter.py +++ b/tests/test_emitter.py @@ -398,39 +398,23 @@ def test_renaming_top_level_directory(): start_watching() mkdir(p('a')) - event = event_queue.get(timeout=5)[0] - assert isinstance(event, DirCreatedEvent) - assert event.src_path == p('a') - event = event_queue.get(timeout=5)[0] - assert isinstance(event, DirModifiedEvent) - assert event.src_path == p() + expect_event(DirCreatedEvent(p('a'))) + expect_event(DirModifiedEvent(p())) mkdir(p('a', 'b')) - event = event_queue.get(timeout=5)[0] - assert isinstance(event, DirCreatedEvent) - assert event.src_path == p('a', 'b') - event = event_queue.get(timeout=5)[0] - assert isinstance(event, DirModifiedEvent) - assert event.src_path == p('a') + expect_event(DirCreatedEvent(p('a', 'b'))) + expect_event(DirModifiedEvent(p('a'))) mv(p('a'), p('a2')) - event = event_queue.get(timeout=5)[0] - assert event.src_path == p('a') - event = event_queue.get(timeout=5)[0] - assert isinstance(event, DirModifiedEvent) - assert event.src_path == p() - event = event_queue.get(timeout=5)[0] - assert isinstance(event, DirModifiedEvent) - assert event.src_path == p() + expect_event(DirMovedEvent(p('a'), p('a2'))) + expect_event(DirModifiedEvent(p())) + expect_event(DirModifiedEvent(p())) - event = event_queue.get(timeout=5)[0] - assert isinstance(event, DirMovedEvent) - assert event.src_path == p('a', 'b') + if not platform.is_darwin(): + expect_event(DirMovedEvent(p('a'), p('a2'))) if platform.is_bsd(): - event = event_queue.get(timeout=5)[0] - assert isinstance(event, DirModifiedEvent) - assert event.src_path == p() + expect_event(DirModifiedEvent(p())) open(p('a2', 'b', 'c'), 'a').close() From 78267eb4201e0b91f4c041b5938eecb6c85ad963 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 7 Jan 2021 16:50:03 +0100 Subject: [PATCH 11/13] Generate sub events for move operations --- src/watchdog/observers/fsevents.py | 10 +++++++--- tests/test_emitter.py | 26 ++++++-------------------- 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/src/watchdog/observers/fsevents.py b/src/watchdog/observers/fsevents.py index 35ddd4644..13c4ceb62 100644 --- a/src/watchdog/observers/fsevents.py +++ b/src/watchdog/observers/fsevents.py @@ -37,7 +37,8 @@ DirDeletedEvent, DirModifiedEvent, DirCreatedEvent, - DirMovedEvent + DirMovedEvent, + generate_sub_moved_events ) from watchdog.observers.api import ( @@ -117,6 +118,9 @@ def queue_events(self, timeout, events): self.queue_event(cls(src_path, dst_path)) self.queue_event(DirModifiedEvent(os.path.dirname(src_path))) self.queue_event(DirModifiedEvent(os.path.dirname(dst_path))) + for sub_event in generate_sub_moved_events(src_path, dst_path): + logger.info("Generated sub event: %s", sub_event) + self.queue_event(sub_event) i += 1 elif os.path.exists(event.path): cls = DirCreatedEvent if event.is_directory else FileCreatedEvent @@ -126,12 +130,12 @@ def queue_events(self, timeout, events): cls = DirDeletedEvent if event.is_directory else FileDeletedEvent self.queue_event(cls(src_path)) self.queue_event(DirModifiedEvent(os.path.dirname(src_path))) - # TODO: generate events for tree if event.is_created: cls = DirCreatedEvent if event.is_directory else FileCreatedEvent if not event.is_coalesced or ( - event.is_coalesced and not event.is_renamed and os.path.exists(event.path) + event.is_coalesced and not event.is_renamed and not event.is_modified and not + event.is_inode_meta_mod and not event.is_xattr_mod ): if src_path not in self.filesystem_view: self.filesystem_view[src_path] = os.stat(src_path) diff --git a/tests/test_emitter.py b/tests/test_emitter.py index cd68e4549..3fb8a611b 100644 --- a/tests/test_emitter.py +++ b/tests/test_emitter.py @@ -410,8 +410,7 @@ def test_renaming_top_level_directory(): expect_event(DirModifiedEvent(p())) expect_event(DirModifiedEvent(p())) - if not platform.is_darwin(): - expect_event(DirMovedEvent(p('a'), p('a2'))) + expect_event(DirMovedEvent(p('a', 'b'), p('a2', 'b'))) if platform.is_bsd(): expect_event(DirModifiedEvent(p())) @@ -497,25 +496,12 @@ def test_move_nested_subdirectories(): start_watching() mv(p('dir1/dir2'), p('dir2')) - event = event_queue.get(timeout=5)[0] - assert event.src_path == p('dir1', 'dir2') - assert isinstance(event, DirMovedEvent) - - event = event_queue.get(timeout=5)[0] - assert event.src_path == p('dir1') - assert isinstance(event, DirModifiedEvent) - - event = event_queue.get(timeout=5)[0] - assert p(event.src_path, '') == p('') - assert isinstance(event, DirModifiedEvent) - - event = event_queue.get(timeout=5)[0] - assert event.src_path == p('dir1/dir2/dir3') - assert isinstance(event, DirMovedEvent) + expect_event(DirMovedEvent(p('dir1', 'dir2'), p('dir2'))) + expect_event(DirModifiedEvent(p('dir1'))) + expect_event(DirModifiedEvent(p())) - event = event_queue.get(timeout=5)[0] - assert event.src_path == p('dir1/dir2/dir3', 'a') - assert isinstance(event, FileMovedEvent) + expect_event(DirMovedEvent(p('dir1', 'dir2', 'dir3'), p('dir2', 'dir3'))) + expect_event(FileMovedEvent(p('dir1', 'dir2', 'dir3', 'a'), p('dir2', 'dir3', 'a'))) if platform.is_bsd(): event = event_queue.get(timeout=5)[0] From 4d542dd8e4f4676194800f3d23eefe8c31922960 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 12 Jan 2021 10:25:21 +0100 Subject: [PATCH 12/13] Remove `filesystem_view` again While the `filesystem_view` helps with filtering out additional `FileCreatedEvent`+`DirModifiedEvent` pairs then it also introduces a huge amount of edge cases for synthetic events caused by move and rename operations. On top of that, in order to properly resolve those edge cases we'd have to go back to a solution very similar to the old directory snapshots, with all the performance penalties they suffered from... As such I think it's better to acknowledge the behaviour for coalesced events instead, and thus remove the `filesystem_view` again. --- src/watchdog/observers/fsevents.py | 30 ++++++------------------------ tests/test_emitter.py | 7 +++++++ 2 files changed, 13 insertions(+), 24 deletions(-) diff --git a/src/watchdog/observers/fsevents.py b/src/watchdog/observers/fsevents.py index 13c4ceb62..080ed59d9 100644 --- a/src/watchdog/observers/fsevents.py +++ b/src/watchdog/observers/fsevents.py @@ -38,6 +38,7 @@ DirModifiedEvent, DirCreatedEvent, DirMovedEvent, + generate_sub_created_events, generate_sub_moved_events ) @@ -71,15 +72,12 @@ class FSEventsEmitter(EventEmitter): def __init__(self, event_queue, watch, timeout=DEFAULT_EMITTER_TIMEOUT): EventEmitter.__init__(self, event_queue, watch, timeout) self._lock = threading.Lock() - # a dictionary of event.path -> posix.stat_result - self.filesystem_view = {} def on_thread_stop(self): if self.watch: _fsevents.remove_watch(self.watch) _fsevents.stop(self) self._watch = None - self.filesystem_view.clear() def queue_event(self, event): logger.info("queue_event %s", event) @@ -92,17 +90,6 @@ def queue_events(self, timeout, events): logger.info(event) src_path = self._encode_path(event.path) - """ - FIXME: It is not enough to just de-duplicate the events based on - whether they are coalesced or not. We must also take into - account old and new state. As such we need to track all - events that occurred so that we can make a correct decision - about which events should be generated. - """ - - # For some reason the create and remove flags are sometimes also - # set for rename and modify type events, so let those take - # precedence. if event.is_renamed: # Internal moves appears to always be consecutive in the same # buffer and have IDs differ by exactly one (while others @@ -126,6 +113,8 @@ def queue_events(self, timeout, events): cls = DirCreatedEvent if event.is_directory else FileCreatedEvent self.queue_event(cls(src_path)) self.queue_event(DirModifiedEvent(os.path.dirname(src_path))) + for sub_event in generate_sub_created_events(src_path): + self.queue_event(sub_event) else: cls = DirDeletedEvent if event.is_directory else FileDeletedEvent self.queue_event(cls(src_path)) @@ -137,18 +126,12 @@ def queue_events(self, timeout, events): event.is_coalesced and not event.is_renamed and not event.is_modified and not event.is_inode_meta_mod and not event.is_xattr_mod ): - if src_path not in self.filesystem_view: - self.filesystem_view[src_path] = os.stat(src_path) - self.queue_event(cls(src_path)) - self.queue_event(DirModifiedEvent(os.path.dirname(src_path))) + self.queue_event(cls(src_path)) + self.queue_event(DirModifiedEvent(os.path.dirname(src_path))) if event.is_modified and not event.is_coalesced and os.path.exists(src_path): cls = DirModifiedEvent if event.is_directory else FileModifiedEvent - new = os.stat(src_path) - old = self.filesystem_view.get(src_path, None) - if new != old: - self.queue_event(cls(src_path)) - self.filesystem_view[src_path] = new + self.queue_event(cls(src_path)) if event.is_inode_meta_mod or event.is_xattr_mod: if os.path.exists(src_path) and not event.is_coalesced: @@ -161,7 +144,6 @@ def queue_events(self, timeout, events): if not event.is_coalesced or (event.is_coalesced and not os.path.exists(event.path)): self.queue_event(cls(src_path)) self.queue_event(DirModifiedEvent(os.path.dirname(src_path))) - self.filesystem_view.pop(src_path, None) if src_path == self.watch.path: # this should not really occur, instead we expect diff --git a/tests/test_emitter.py b/tests/test_emitter.py index 3fb8a611b..d0e99355d 100644 --- a/tests/test_emitter.py +++ b/tests/test_emitter.py @@ -184,6 +184,13 @@ def test_modify(): touch(p('a')) + # Because the tests run so fast then on macOS it is almost certain that + # we receive a coalesced event from fseventsd here, which triggers an + # additional file created event and dir modified event here. + if platform.is_darwin(): + expect_event(FileCreatedEvent(p('a'))) + expect_event(DirModifiedEvent(p())) + expect_event(FileModifiedEvent(p('a'))) From 26a48f2c39196898db9e8a0c72a69c0fec05fe3e Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 18 Jan 2021 14:13:59 +0100 Subject: [PATCH 13/13] Update Changelog --- changelog.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/changelog.rst b/changelog.rst index 167f5cba3..0ef131202 100644 --- a/changelog.rst +++ b/changelog.rst @@ -9,7 +9,8 @@ Changelog 202x-xx-xx • `full history `__ - Avoid deprecated ``PyEval_InitThreads`` on Python 3.7+ (`#746 `_) -- Thanks to our beloved contributors: @bstaletic +- [mac] Support coalesced filesystem events (`#734 `_) +- Thanks to our beloved contributors: @bstaletic, @SamSchott, @CCP-Aporia 1.0.2