diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6afed2875..6cdaf18ac 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,6 +6,13 @@ on: - master pull_request: + workflow_dispatch: + inputs: + branch: + description: 'The branch, tag or SHA to release from' + required: true + default: 'master' + concurrency: group: ${{ github.ref }}-${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name != 'pull_request' && github.sha || '' }} cancel-in-progress: true diff --git a/src/watchdog/events.py b/src/watchdog/events.py index 8b8d6348b..caa54b070 100644 --- a/src/watchdog/events.py +++ b/src/watchdog/events.py @@ -69,6 +69,15 @@ :members: :show-inheritance: +.. autoclass:: FileAttribEvent + :members: + :show-inheritance: + +.. autoclass:: DirAttribEvent + :members: + :show-inheritance: + + Event Handler Classes --------------------- @@ -106,6 +115,7 @@ EVENT_TYPE_MODIFIED = "modified" EVENT_TYPE_CLOSED = "closed" EVENT_TYPE_OPENED = "opened" +EVENT_TYPE_ATTRIB = "attrib" @dataclass(unsafe_hash=True) @@ -145,6 +155,14 @@ class FileDeletedEvent(FileSystemEvent): event_type = EVENT_TYPE_DELETED +class FileAttribEvent(FileSystemEvent): + """ + File system event representing file metadata modification on the file system. + """ + + event_type = EVENT_TYPE_ATTRIB + + class FileModifiedEvent(FileSystemEvent): """File system event representing file modification on the file system.""" @@ -203,6 +221,15 @@ class DirMovedEvent(FileSystemMovedEvent): is_directory = True +class DirAttribEvent(FileSystemEvent): + """ + File system event representing directory metadata modification on the file system. + """ + + event_type = EVENT_TYPE_ATTRIB + is_directory = True + + class FileSystemEventHandler: """Base file system event handler that you can override methods from.""" @@ -222,6 +249,7 @@ def dispatch(self, event: FileSystemEvent) -> None: EVENT_TYPE_MOVED: self.on_moved, EVENT_TYPE_CLOSED: self.on_closed, EVENT_TYPE_OPENED: self.on_opened, + EVENT_TYPE_ATTRIB: self.on_attrib, }[event.event_type](event) def on_any_event(self, event: FileSystemEvent) -> None: @@ -287,6 +315,15 @@ def on_opened(self, event: FileSystemEvent) -> None: :class:`FileOpenedEvent` """ + def on_attrib(self, event: FileSystemEvent) -> None: + """Called when a file or directory metadata is modified. + + :param event: + Event representing file/directory metadata modification. + :type event: + :class:`FileAttribEvent` or :class:`DirAttribEvent` + """ + class PatternMatchingEventHandler(FileSystemEventHandler): """ @@ -478,6 +515,12 @@ def on_modified(self, event: FileSystemEvent) -> None: what = "directory" if event.is_directory else "file" self.logger.info("Modified %s: %s", what, event.src_path) + def on_attrib(self, event): + super().on_attrib(event) + + what = "directory" if event.is_directory else "file" + self.logger.info("Attrib %s: %s", what, event.src_path) + def on_closed(self, event: FileSystemEvent) -> None: super().on_closed(event) diff --git a/src/watchdog/observers/fsevents.py b/src/watchdog/observers/fsevents.py index c3b29cd0b..a3a8fef57 100644 --- a/src/watchdog/observers/fsevents.py +++ b/src/watchdog/observers/fsevents.py @@ -31,10 +31,12 @@ import _watchdog_fsevents as _fsevents from watchdog.events import ( + DirAttribEvent, DirCreatedEvent, DirDeletedEvent, DirModifiedEvent, DirMovedEvent, + FileAttribEvent, FileCreatedEvent, FileDeletedEvent, FileModifiedEvent, @@ -128,6 +130,10 @@ def _queue_modified_event(self, event, src_path, dirname): cls = DirModifiedEvent if event.is_directory else FileModifiedEvent self.queue_event(cls(src_path)) + def _queue_attrib_event(self, event, src_path, dirname): + cls = DirAttribEvent if event.is_directory else FileAttribEvent + self.queue_event(cls(src_path)) + def _queue_renamed_event(self, src_event, src_path, dst_path, src_dirname, dst_dirname): cls = DirMovedEvent if src_event.is_directory else FileMovedEvent dst_path = self._encode_path(dst_path) @@ -208,9 +214,12 @@ def queue_events(self, timeout, events): self._fs_view.add(event.inode) - if event.is_modified or self._is_meta_mod(event): + if event.is_modified: self._queue_modified_event(event, src_path, src_dirname) + elif self._is_meta_mod(event): + self._queue_attrib_event(event, src_path, src_dirname) + self._queue_deleted_event(event, src_path, src_dirname) self._fs_view.discard(event.inode) @@ -220,10 +229,13 @@ def queue_events(self, timeout, events): self._fs_view.add(event.inode) - if event.is_modified or self._is_meta_mod(event): + if event.is_modified: self._queue_modified_event(event, src_path, src_dirname) - if event.is_renamed: + elif self._is_meta_mod(event): + self._queue_attrib_event(event, src_path, src_dirname) + + elif event.is_renamed: # Check if we have a corresponding destination event in the watched path. dst_event = next( iter(e for e in events if e.is_renamed and e.inode == event.inode), @@ -247,10 +259,13 @@ def queue_events(self, timeout, events): events.remove(dst_event) - if dst_event.is_modified or self._is_meta_mod(dst_event): + if dst_event.is_modified: self._queue_modified_event(dst_event, dst_path, dst_dirname) - if dst_event.is_removed: + elif self._is_meta_mod(dst_event): + self._queue_attrib_event(dst_event, dst_path, dst_dirname) + + elif dst_event.is_removed: self._queue_deleted_event(dst_event, dst_path, dst_dirname) self._fs_view.discard(dst_event.inode) @@ -272,7 +287,7 @@ def queue_events(self, timeout, events): # Skip further coalesced processing. continue - if event.is_removed: + elif event.is_removed: # Won't occur together with renamed. self._queue_deleted_event(event, src_path, src_dirname) self._fs_view.discard(event.inode) diff --git a/src/watchdog/observers/fsevents2.py b/src/watchdog/observers/fsevents2.py index 72e05bfd2..d6aa41cf3 100644 --- a/src/watchdog/observers/fsevents2.py +++ b/src/watchdog/observers/fsevents2.py @@ -57,10 +57,12 @@ ) from watchdog.events import ( + DirAttribEvent, DirCreatedEvent, DirDeletedEvent, DirModifiedEvent, DirMovedEvent, + FileAttribEvent, FileCreatedEvent, FileDeletedEvent, FileModifiedEvent, @@ -191,6 +193,7 @@ def queue_events(self, timeout): events = self._fsevents.read_events() if events is None: return + i = 0 while i < len(events): event = events[i] @@ -222,10 +225,14 @@ def queue_events(self, timeout): self.queue_event(DirModifiedEvent(os.path.dirname(event.path))) # TODO: generate events for tree - elif event.is_modified or event.is_inode_meta_mod or event.is_xattr_mod: + elif event.is_modified: cls = DirModifiedEvent if event.is_directory else FileModifiedEvent self.queue_event(cls(event.path)) + elif event.is_inode_meta_mod or event.is_xattr_mod: + cls = DirAttribEvent if event.is_directory else FileAttribEvent + self.queue_event(cls(event.path)) + elif event.is_created: cls = DirCreatedEvent if event.is_directory else FileCreatedEvent self.queue_event(cls(event.path)) @@ -235,6 +242,7 @@ def queue_events(self, timeout): cls = DirDeletedEvent if event.is_directory else FileDeletedEvent self.queue_event(cls(event.path)) self.queue_event(DirModifiedEvent(os.path.dirname(event.path))) + i += 1 diff --git a/src/watchdog/observers/inotify.py b/src/watchdog/observers/inotify.py index a69f92368..e29659aca 100644 --- a/src/watchdog/observers/inotify.py +++ b/src/watchdog/observers/inotify.py @@ -70,10 +70,12 @@ import threading from watchdog.events import ( + DirAttribEvent, DirCreatedEvent, DirDeletedEvent, DirModifiedEvent, DirMovedEvent, + FileAttribEvent, FileClosedEvent, FileCreatedEvent, FileDeletedEvent, @@ -166,7 +168,10 @@ def queue_events(self, timeout, full_events=False): if event.is_directory and self.watch.is_recursive: for sub_event in generate_sub_created_events(src_path): self.queue_event(sub_event) - elif event.is_attrib or event.is_modify: + elif event.is_attrib: + cls = DirAttribEvent if event.is_directory else FileAttribEvent + self.queue_event(cls(src_path)) + elif event.is_modify: cls = DirModifiedEvent if event.is_directory else FileModifiedEvent self.queue_event(cls(src_path)) elif event.is_delete or (event.is_moved_from and not full_events): diff --git a/src/watchdog/observers/kqueue.py b/src/watchdog/observers/kqueue.py index 7233af3b1..8b14f33a8 100644 --- a/src/watchdog/observers/kqueue.py +++ b/src/watchdog/observers/kqueue.py @@ -84,10 +84,12 @@ EVENT_TYPE_CREATED, EVENT_TYPE_DELETED, EVENT_TYPE_MOVED, + DirAttribEvent, DirCreatedEvent, DirDeletedEvent, DirModifiedEvent, DirMovedEvent, + FileAttribEvent, FileCreatedEvent, FileDeletedEvent, FileModifiedEvent, @@ -122,7 +124,6 @@ def absolute_path(path): return os.path.abspath(os.path.normpath(path)) - # Flag tests. @@ -515,9 +516,9 @@ def _gen_kqueue_events(self, kev, ref_snapshot, new_snapshot): yield from self._gen_renamed_events(src_path, descriptor.is_directory, ref_snapshot, new_snapshot) elif is_attrib_modified(kev): if descriptor.is_directory: - yield DirModifiedEvent(src_path) + yield DirAttribEvent(src_path) else: - yield FileModifiedEvent(src_path) + yield FileAttribEvent(src_path) elif is_modified(kev): if descriptor.is_directory: if self.watch.is_recursive or self.watch.path == src_path: diff --git a/tests/conftest.py b/tests/conftest.py index 0717e13a5..f26e1811e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,7 @@ import pytest -from .utils import ExpectEvent, Helper, P, StartWatching, TestEventQueue +from .utils import ExpectAnyEvent, ExpectEvent, Helper, P, StartWatching, TestEventQueue @pytest.fixture() @@ -79,3 +79,8 @@ def start_watching_fixture(helper: Helper) -> StartWatching: @pytest.fixture(name="expect_event") def expect_event_fixture(helper: Helper) -> ExpectEvent: return helper.expect_event + + +@pytest.fixture(name="expect_any_event") +def expect_any_event_fixture(helper: Helper) -> ExpectAnyEvent: + return helper.expect_any_event diff --git a/tests/shell.py b/tests/shell.py index 18807f809..a2f76a3e0 100644 --- a/tests/shell.py +++ b/tests/shell.py @@ -28,15 +28,6 @@ import tempfile import time -# def tree(path='.', show_files=False): -# print(path) -# padding = '' -# for root, directories, filenames in os.walk(path): -# print(padding + os.path.basename(root) + os.path.sep) -# padding = padding + ' ' -# for filename in filenames: -# print(padding + filename) - def cd(path): os.chdir(path) @@ -124,6 +115,11 @@ def msize(path): os.utime(path, (0, 0)) +def chmod(path, mode): + """Change file mode bits.""" + os.chmod(path, mode) + + def mount_tmpfs(path): os.system(f"sudo mount -t tmpfs none {path}") diff --git a/tests/test_emitter.py b/tests/test_emitter.py index 300727b31..ddb94c03e 100644 --- a/tests/test_emitter.py +++ b/tests/test_emitter.py @@ -23,10 +23,12 @@ import pytest from watchdog.events import ( + DirAttribEvent, DirCreatedEvent, DirDeletedEvent, DirModifiedEvent, DirMovedEvent, + FileAttribEvent, FileClosedEvent, FileCreatedEvent, FileDeletedEvent, @@ -36,8 +38,8 @@ ) from watchdog.utils import platform -from .shell import mkdir, mkfile, mv, rm, touch -from .utils import ExpectEvent, P, StartWatching, TestEventQueue +from .shell import chmod, mkdir, mkfile, mv, rm, touch +from .utils import ExpectAnyEvent, ExpectEvent, P, StartWatching, TestEventQueue logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) @@ -55,7 +57,7 @@ def rerun_filter(exc, *args): @pytest.mark.flaky(max_runs=5, min_passes=1, rerun_filter=rerun_filter) -def test_create(p: P, event_queue: TestEventQueue, start_watching: StartWatching, expect_event: ExpectEvent) -> None: +def test_create(p: P, start_watching: StartWatching, expect_event: ExpectEvent) -> None: start_watching() open(p("a"), "a").close() @@ -65,35 +67,29 @@ def test_create(p: P, event_queue: TestEventQueue, start_watching: StartWatching expect_event(DirModifiedEvent(p())) if platform.is_linux(): - event = event_queue.get(timeout=5)[0] - assert event.src_path == p("a") - assert isinstance(event, FileOpenedEvent) - event = event_queue.get(timeout=5)[0] - assert event.src_path == p("a") - assert isinstance(event, FileClosedEvent) + expect_event(FileOpenedEvent(p("a"))) + expect_event(FileClosedEvent(p("a"))) @pytest.mark.xfail(reason="known to be problematic") @pytest.mark.skipif(not platform.is_linux(), reason="FileCloseEvent only supported in GNU/Linux") @pytest.mark.flaky(max_runs=5, min_passes=1, rerun_filter=rerun_filter) -def test_close(p: P, event_queue: TestEventQueue, start_watching: StartWatching) -> None: +def test_close(p: P, start_watching: StartWatching, expect_event: ExpectEvent) -> None: f_d = open(p("a"), "a") start_watching() f_d.close() # After file creation/open in append mode - event = event_queue.get(timeout=5)[0] - assert event.src_path == p("a") - assert isinstance(event, FileClosedEvent) + expect_event(FileClosedEvent(p("a"))) - 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(os.path.normpath(p("")))) # After read-only, only IN_CLOSE_NOWRITE is emitted but not caught for now #747 open(p("a"), "r").close() - assert event_queue.empty() + expect_event(FileOpenedEvent(p("a"))) + with pytest.raises(Empty): + expect_event(FileOpenedEvent(p("a"))) # Fake event to check the queue is empty @pytest.mark.flaky(max_runs=5, min_passes=1, rerun_filter=rerun_filter) @@ -101,24 +97,19 @@ def test_close(p: P, event_queue: TestEventQueue, start_watching: StartWatching) platform.is_darwin() or platform.is_windows(), reason="Windows and macOS enforce proper encoding", ) -def test_create_wrong_encoding(p: P, event_queue: TestEventQueue, start_watching: StartWatching) -> None: +def test_create_wrong_encoding(p: P, start_watching: StartWatching, expect_event: ExpectEvent) -> None: start_watching() open(p("a_\udce4"), "a").close() - event = event_queue.get(timeout=5)[0] - assert event.src_path == p("a_\udce4") - assert isinstance(event, FileCreatedEvent) + expect_event(FileCreatedEvent(p("a_\udce4"))) 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(os.path.normpath(p("")))) @pytest.mark.flaky(max_runs=5, min_passes=1, rerun_filter=rerun_filter) def test_delete(p: P, start_watching: StartWatching, expect_event: ExpectEvent) -> None: mkfile(p("a")) - start_watching() rm(p("a")) @@ -129,23 +120,21 @@ def test_delete(p: P, start_watching: StartWatching, expect_event: ExpectEvent) @pytest.mark.flaky(max_runs=5, min_passes=1, rerun_filter=rerun_filter) -def test_modify(p: P, event_queue: TestEventQueue, start_watching: StartWatching, expect_event: ExpectEvent) -> None: +def test_modify(p: P, start_watching: StartWatching, expect_event: ExpectEvent) -> None: mkfile(p("a")) start_watching() touch(p("a")) - if platform.is_linux(): - event = event_queue.get(timeout=5)[0] - assert event.src_path == p("a") - assert isinstance(event, FileOpenedEvent) - - expect_event(FileModifiedEvent(p("a"))) - - if platform.is_linux(): - event = event_queue.get(timeout=5)[0] - assert event.src_path == p("a") - assert isinstance(event, FileClosedEvent) + if platform.is_windows(): + expect_event(FileModifiedEvent(p("a"))) + elif platform.is_linux(): + expect_event(FileOpenedEvent(p("a"))) + expect_event(FileAttribEvent(p("a"))) + expect_event(FileClosedEvent(p("a"))) + elif platform.is_bsd(): + expect_event(FileModifiedEvent(p("a"))) + expect_event(FileAttribEvent(p("a"))) @pytest.mark.flaky(max_runs=5, min_passes=1, rerun_filter=rerun_filter) @@ -157,14 +146,17 @@ def test_chmod(p: P, start_watching: StartWatching, expect_event: ExpectEvent) - # allows setting the read-only flag. os.chmod(p("a"), stat.S_IREAD) - expect_event(FileModifiedEvent(p("a"))) + if platform.is_windows(): + expect_event(FileModifiedEvent(p("a"))) + else: + expect_event(FileAttribEvent(p("a"))) # Reset permissions to allow cleanup. os.chmod(p("a"), stat.S_IWRITE) @pytest.mark.flaky(max_runs=5, min_passes=1, rerun_filter=rerun_filter) -def test_move(p: P, event_queue: TestEventQueue, start_watching: StartWatching, expect_event: ExpectEvent) -> None: +def test_move(p: P, start_watching: StartWatching, expect_any_event: ExpectAnyEvent, expect_event: ExpectEvent) -> None: mkdir(p("dir1")) mkdir(p("dir2")) mkfile(p("dir1", "a")) @@ -172,31 +164,23 @@ def test_move(p: P, event_queue: TestEventQueue, start_watching: StartWatching, mv(p("dir1", "a"), p("dir2", "b")) - if not platform.is_windows(): - expect_event(FileMovedEvent(p("dir1", "a"), p("dir2", "b"))) + if platform.is_windows(): + expect_event(FileDeletedEvent(p("dir1", "a"))) + expect_event(FileCreatedEvent(p("dir2", "b"))) else: - event = event_queue.get(timeout=5)[0] - assert event.src_path == p("dir1", "a") - assert isinstance(event, FileDeletedEvent) - event = event_queue.get(timeout=5)[0] - assert event.src_path == p("dir2", "b") - assert isinstance(event, FileCreatedEvent) + expect_event(FileMovedEvent(p("dir1", "a"), p("dir2", "b"))) - event = event_queue.get(timeout=5)[0] - assert event.src_path in [p("dir1"), p("dir2")] - assert isinstance(event, DirModifiedEvent) + expect_any_event(DirModifiedEvent(p("dir1")), DirModifiedEvent(p("dir2"))) if not platform.is_windows(): - event = event_queue.get(timeout=5)[0] - assert event.src_path in [p("dir1"), p("dir2")] - assert isinstance(event, DirModifiedEvent) + expect_any_event(DirModifiedEvent(p("dir1")), DirModifiedEvent(p("dir2"))) @pytest.mark.flaky(max_runs=5, min_passes=1, rerun_filter=rerun_filter) def test_case_change( p: P, - event_queue: TestEventQueue, start_watching: StartWatching, + expect_any_event: ExpectAnyEvent, expect_event: ExpectEvent, ) -> None: mkdir(p("dir1")) @@ -206,24 +190,16 @@ def test_case_change( mv(p("dir1", "file"), p("dir2", "FILE")) - if not platform.is_windows(): - expect_event(FileMovedEvent(p("dir1", "file"), p("dir2", "FILE"))) + if platform.is_windows(): + expect_event(FileDeletedEvent(p("dir1", "file"))) + expect_event(FileCreatedEvent(p("dir2", "FILE"))) else: - event = event_queue.get(timeout=5)[0] - assert event.src_path == p("dir1", "file") - assert isinstance(event, FileDeletedEvent) - event = event_queue.get(timeout=5)[0] - assert event.src_path == p("dir2", "FILE") - assert isinstance(event, FileCreatedEvent) + expect_event(FileMovedEvent(p("dir1", "file"), p("dir2", "FILE"))) - event = event_queue.get(timeout=5)[0] - assert event.src_path in [p("dir1"), p("dir2")] - assert isinstance(event, DirModifiedEvent) + expect_any_event(DirModifiedEvent(p("dir1")), DirModifiedEvent(p("dir2"))) if not platform.is_windows(): - event = event_queue.get(timeout=5)[0] - assert event.src_path in [p("dir1"), p("dir2")] - assert isinstance(event, DirModifiedEvent) + expect_any_event(DirModifiedEvent(p("dir1")), DirModifiedEvent(p("dir2"))) @pytest.mark.flaky(max_runs=5, min_passes=1, rerun_filter=rerun_filter) @@ -242,17 +218,16 @@ def test_move_to(p: P, start_watching: StartWatching, expect_event: ExpectEvent) @pytest.mark.skipif(not platform.is_linux(), reason="InotifyFullEmitter only supported in Linux") -def test_move_to_full(p: P, event_queue: TestEventQueue, start_watching: StartWatching) -> None: +def test_move_to_full(p: P, start_watching: StartWatching, expect_event: ExpectEvent) -> None: mkdir(p("dir1")) mkdir(p("dir2")) mkfile(p("dir1", "a")) start_watching(p("dir2"), use_full_emitter=True) + mv(p("dir1", "a"), p("dir2", "b")) - event = event_queue.get(timeout=5)[0] - assert isinstance(event, FileMovedEvent) - assert event.dest_path == p("dir2", "b") - assert event.src_path == "" # Should be blank since the path was not watched + # `src_path` should be blank since the path was not watched + expect_event(FileMovedEvent("", p("dir2", "b"))) @pytest.mark.flaky(max_runs=5, min_passes=1, rerun_filter=rerun_filter) @@ -263,7 +238,6 @@ def test_move_from(p: P, start_watching: StartWatching, expect_event: ExpectEven start_watching(p("dir1")) mv(p("dir1", "a"), p("dir2", "b")) - expect_event(FileDeletedEvent(p("dir1", "a"))) if not platform.is_windows(): @@ -271,17 +245,16 @@ def test_move_from(p: P, start_watching: StartWatching, expect_event: ExpectEven @pytest.mark.skipif(not platform.is_linux(), reason="InotifyFullEmitter only supported in Linux") -def test_move_from_full(p: P, event_queue: TestEventQueue, start_watching: StartWatching) -> None: +def test_move_from_full(p: P, start_watching: StartWatching, expect_event: ExpectEvent) -> None: mkdir(p("dir1")) mkdir(p("dir2")) mkfile(p("dir1", "a")) start_watching(p("dir1"), use_full_emitter=True) + mv(p("dir1", "a"), p("dir2", "b")) - event = event_queue.get(timeout=5)[0] - assert isinstance(event, FileMovedEvent) - assert event.src_path == p("dir1", "a") - assert event.dest_path == "" # Should be blank since path not watched + # `dest_path` should be blank since the path was not watched + expect_event(FileMovedEvent(p("dir1", "a"), "")) @pytest.mark.flaky(max_runs=5, min_passes=1, rerun_filter=rerun_filter) @@ -367,7 +340,7 @@ def test_passing_unicode_should_give_unicode(p: P, event_queue: TestEventQueue, @pytest.mark.skipif( platform.is_windows(), - reason="Windows ReadDirectoryChangesW supports only" " unicode for paths.", + reason="Windows ReadDirectoryChangesW supports only unicode for paths.", ) def test_passing_bytes_should_give_bytes(p: P, event_queue: TestEventQueue, start_watching: StartWatching) -> None: start_watching(p().encode()) @@ -377,29 +350,20 @@ def test_passing_bytes_should_give_bytes(p: P, event_queue: TestEventQueue, star @pytest.mark.flaky(max_runs=5, min_passes=1, rerun_filter=rerun_filter) -def test_recursive_on(p: P, event_queue: TestEventQueue, start_watching: StartWatching) -> None: +def test_recursive_on(p: P, start_watching: StartWatching, expect_event: ExpectEvent) -> None: mkdir(p("dir1", "dir2", "dir3"), True) start_watching() touch(p("dir1", "dir2", "dir3", "a")) - event = event_queue.get(timeout=5)[0] - assert event.src_path == p("dir1", "dir2", "dir3", "a") - assert isinstance(event, FileCreatedEvent) + expect_event(FileCreatedEvent(p("dir1", "dir2", "dir3", "a"))) if not platform.is_windows(): - event = event_queue.get(timeout=5)[0] - assert event.src_path == p("dir1", "dir2", "dir3") - assert isinstance(event, DirModifiedEvent) - + expect_event(DirModifiedEvent(p("dir1", "dir2", "dir3"))) if platform.is_linux(): - event = event_queue.get(timeout=5)[0] - assert event.src_path == p("dir1", "dir2", "dir3", "a") - assert isinstance(event, FileOpenedEvent) - - if not platform.is_bsd(): - event = event_queue.get(timeout=5)[0] - assert event.src_path == p("dir1", "dir2", "dir3", "a") - assert isinstance(event, FileModifiedEvent) + expect_event(FileOpenedEvent(p("dir1", "dir2", "dir3", "a"))) + expect_event(FileAttribEvent(p("dir1", "dir2", "dir3", "a"))) + if platform.is_linux(): + expect_event(FileClosedEvent(p("dir1", "dir2", "dir3", "a"))) @pytest.mark.flaky(max_runs=5, min_passes=1, rerun_filter=rerun_filter) @@ -421,9 +385,9 @@ def test_recursive_off( if not platform.is_windows(): expect_event(DirModifiedEvent(p())) - if platform.is_linux(): - expect_event(FileOpenedEvent(p("b"))) - expect_event(FileClosedEvent(p("b"))) + if platform.is_linux(): + expect_event(FileOpenedEvent(p("b"))) + expect_event(FileClosedEvent(p("b"))) # currently limiting these additional events to macOS only, see https://github.com/gorakhargosh/watchdog/pull/779 if platform.is_darwin(): @@ -499,12 +463,7 @@ def test_renaming_top_level_directory( @pytest.mark.skipif(platform.is_windows(), reason="Windows create another set of events for this test") -def test_move_nested_subdirectories( - p: P, - event_queue: TestEventQueue, - start_watching: StartWatching, - expect_event: ExpectEvent, -) -> None: +def test_move_nested_subdirectories(p: P, start_watching: StartWatching, expect_event: ExpectEvent) -> None: mkdir(p("dir1/dir2/dir3"), parents=True) mkfile(p("dir1/dir2/dir3", "a")) start_watching() @@ -513,29 +472,25 @@ def test_move_nested_subdirectories( expect_event(DirMovedEvent(p("dir1", "dir2"), p("dir2"))) expect_event(DirModifiedEvent(p("dir1"))) expect_event(DirModifiedEvent(p())) - expect_event(DirMovedEvent(p("dir1", "dir2", "dir3"), p("dir2", "dir3"), is_synthetic=True)) expect_event(FileMovedEvent(p("dir1", "dir2", "dir3", "a"), p("dir2", "dir3", "a"), is_synthetic=True)) if platform.is_bsd(): - 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 p(event.src_path) == p("dir1") - assert isinstance(event, DirModifiedEvent) + expect_event(DirModifiedEvent(p())) + expect_event(DirModifiedEvent(p("dir1"))) touch(p("dir2/dir3", "a")) if platform.is_linux(): - event = event_queue.get(timeout=5)[0] - assert event.src_path == p("dir2/dir3", "a") - assert isinstance(event, FileOpenedEvent) + expect_event(FileOpenedEvent(p("dir2", "dir3", "a"))) - event = event_queue.get(timeout=5)[0] - assert event.src_path == p("dir2/dir3", "a") - assert isinstance(event, FileModifiedEvent) + if not platform.is_windows(): + expect_event(FileAttribEvent(p("dir2", "dir3", "a"))) + + if platform.is_linux(): + expect_event(FileClosedEvent(p("dir2", "dir3", "a"))) + + expect_event(DirModifiedEvent(p("dir2", "dir3"))) @pytest.mark.flaky(max_runs=5, min_passes=1, rerun_filter=rerun_filter) @@ -547,27 +502,17 @@ def test_move_nested_subdirectories_on_windows( p: P, event_queue: TestEventQueue, start_watching: StartWatching, + expect_event: ExpectEvent, ) -> None: mkdir(p("dir1/dir2/dir3"), parents=True) mkfile(p("dir1/dir2/dir3", "a")) start_watching(p("")) mv(p("dir1/dir2"), p("dir2")) - event = event_queue.get(timeout=5)[0] - assert event.src_path == p("dir1", "dir2") - assert isinstance(event, FileDeletedEvent) - - event = event_queue.get(timeout=5)[0] - assert event.src_path == p("dir2") - assert isinstance(event, DirCreatedEvent) - - event = event_queue.get(timeout=5)[0] - assert event.src_path == p("dir2", "dir3") - assert isinstance(event, DirCreatedEvent) - - event = event_queue.get(timeout=5)[0] - assert event.src_path == p("dir2", "dir3", "a") - assert isinstance(event, FileCreatedEvent) + expect_event(FileDeletedEvent(p("dir1", "dir2"))) + expect_event(DirCreatedEvent(p("dir2"))) + expect_event(DirCreatedEvent(p("dir2", "dir3"), is_synthetic=True)) + expect_event(FileCreatedEvent(p("dir2", "dir3", "a"), is_synthetic=True)) touch(p("dir2/dir3", "a")) @@ -577,7 +522,7 @@ def test_move_nested_subdirectories_on_windows( if event_queue.empty(): break - assert all([isinstance(e, (FileModifiedEvent, DirModifiedEvent)) for e in events]) + assert all(isinstance(e, (FileModifiedEvent, DirModifiedEvent)) for e in events) for event in events: if isinstance(event, FileModifiedEvent): @@ -607,7 +552,10 @@ def test_file_lifecyle(p: P, start_watching: StartWatching, expect_event: Expect expect_event(DirModifiedEvent(p())) expect_event(FileOpenedEvent(p("a"))) - expect_event(FileModifiedEvent(p("a"))) + if platform.is_windows(): + expect_event(FileModifiedEvent(p("a"))) + else: + expect_event(FileAttribEvent(p("a"))) if platform.is_linux(): expect_event(FileClosedEvent(p("a"))) @@ -623,3 +571,21 @@ def test_file_lifecyle(p: P, start_watching: StartWatching, expect_event: Expect if not platform.is_windows(): expect_event(DirModifiedEvent(p())) + + +@pytest.mark.skipif(platform.is_windows(), reason="FileAttribEvent isn't supported in Windows") +def test_chmod_file(p: P, start_watching: StartWatching, expect_event: ExpectEvent) -> None: + mkfile(p("newfile")) + start_watching() + + chmod(p("newfile"), 777) + expect_event(FileAttribEvent(p("newfile"))) + + +@pytest.mark.skipif(platform.is_windows(), reason="DirAttribEvent isn't supported in Windows") +def test_chmod_dir(p: P, start_watching: StartWatching, expect_event: ExpectEvent) -> None: + mkdir(p("dir1")) + start_watching() + + chmod(p("dir1"), 777) + expect_event(DirAttribEvent(p("dir1"))) diff --git a/tests/test_events.py b/tests/test_events.py index 3afe5b2e4..31a7ae042 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -16,16 +16,19 @@ from __future__ import annotations from watchdog.events import ( + EVENT_TYPE_ATTRIB, EVENT_TYPE_CLOSED, EVENT_TYPE_CREATED, EVENT_TYPE_DELETED, EVENT_TYPE_MODIFIED, EVENT_TYPE_MOVED, EVENT_TYPE_OPENED, + DirAttribEvent, DirCreatedEvent, DirDeletedEvent, DirModifiedEvent, DirMovedEvent, + FileAttribEvent, FileClosedEvent, FileCreatedEvent, FileDeletedEvent, @@ -94,6 +97,12 @@ def test_file_closed_event(): assert not event.is_synthetic +def test_file_attrib_event(): + event = FileAttribEvent(path_1) + assert path_1 == event.src_path + assert EVENT_TYPE_ATTRIB == event.event_type + + def test_file_opened_event(): event = FileOpenedEvent(path_1) assert path_1 == event.src_path @@ -126,6 +135,14 @@ def test_dir_created_event(): assert not event.is_synthetic +def test_dir_attrib_event(): + event = DirAttribEvent(path_1) + assert path_1 == event.src_path + assert EVENT_TYPE_ATTRIB == event.event_type + assert event.is_directory + assert not event.is_synthetic + + def test_file_system_event_handler_dispatch(): dir_del_event = DirDeletedEvent("/path/blah.py") file_del_event = FileDeletedEvent("/path/blah.txt") @@ -137,6 +154,8 @@ def test_file_system_event_handler_dispatch(): file_mod_event = FileModifiedEvent("/path/blah.txt") dir_mov_event = DirMovedEvent("/path/blah.py", "/path/blah") file_mov_event = FileMovedEvent("/path/blah.txt", "/path/blah") + file_attr_event = FileAttribEvent('/path/blah.py') + dir_attr_event = DirAttribEvent('/path/dir') all_events = [ dir_mod_event, @@ -148,6 +167,8 @@ def test_file_system_event_handler_dispatch(): file_cre_event, file_mov_event, file_cls_event, + file_attr_event, + dir_attr_event, file_opened_event, ] @@ -170,6 +191,9 @@ def on_created(self, event): def on_closed(self, event): assert event.event_type == EVENT_TYPE_CLOSED + def on_attrib(self, event): + assert event.event_type == EVENT_TYPE_ATTRIB + def on_opened(self, event): assert event.event_type == EVENT_TYPE_OPENED diff --git a/tests/utils.py b/tests/utils.py index 91bee6b95..429d45de7 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -39,9 +39,12 @@ def __call__( class ExpectEvent(Protocol): def __call__(self, expected_event: FileSystemEvent, timeout: float = ...) -> None: - ... +class ExpectAnyEvent(Protocol): + def __call__(self, *expected_events: FileSystemEvent, timeout: float = ...) -> None: + ... + TestEventQueue = Queue[tuple[FileSystemEvent, ObservedWatch]] @@ -83,6 +86,18 @@ def start_watching( return emitter + def expect_any_event(self, *expected_events: FileSystemEvent, timeout: float = 2) -> None: + """Utility function to wait up to `timeout` seconds for any `expected_event` + for `path` to show up in the queue. + + Provides some robustness for the otherwise flaky nature of asynchronous notifications. + """ + try: + event = self.event_queue.get(timeout=timeout)[0] + assert event in expected_events + except Empty: + raise + def expect_event(self, expected_event: FileSystemEvent, timeout: float = 2) -> None: """Utility function to wait up to `timeout` seconds for an `event_type` for `path` to show up in the queue.