Skip to content

Commit

Permalink
[inotify] Add support for IN_CLOSE_NOWRITE events
Browse files Browse the repository at this point in the history
A `FileClosedNoWriteEvent` event will be fired,
and its `on_closed_no_write()` dispatcher has been introduced.

Closes gorakhargosh#1046.
  • Loading branch information
BoboTiG committed Aug 12, 2024
1 parent 84d5adb commit b9230ac
Show file tree
Hide file tree
Showing 11 changed files with 111 additions and 68 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,4 @@ jobs:
run: python -m pip install tox

- name: Run ${{ matrix.tox.name }} in tox
run: python -m tox -e ${{ matrix.tox.environment }}
run: python -m tox -q -e ${{ matrix.tox.environment }}
1 change: 1 addition & 0 deletions changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Changelog
- [core] Improve typing references for events (`#1040 <https://github.com/gorakhargosh/watchdog/issues/1040>`__)
- [inotify] Renamed the ``inotify_event_struct`` class to ``InotifyEventStruct`` (`#1055 <https://github.com/gorakhargosh/watchdog/pull/1055>`__)
- [inotify] Renamed the ``UnsupportedLibc`` exception to ``UnsupportedLibcError`` (`#1057 <https://github.com/gorakhargosh/watchdog/pull/1057>`__)
- [inotify] Add support for ``IN_CLOSE_NOWRITE`` events. A ``FileClosedNoWriteEvent`` event will be fired, and its ``on_closed_no_write()`` dispatcher has been introduced (`#1046 <https://github.com/gorakhargosh/watchdog/pull/1046>`__)
- [watchmedo] Renamed the ``LogLevelException`` exception to ``LogLevelError`` (`#1057 <https://github.com/gorakhargosh/watchdog/pull/1057>`__)
- [watchmedo] Renamed the ``WatchdogShutdown`` exception to ``WatchdogShutdownError`` (`#1057 <https://github.com/gorakhargosh/watchdog/pull/1057>`__)
- [windows] Renamed the ``FILE_NOTIFY_INFORMATION`` class to ``FileNotifyInformation`` (`#1055 <https://github.com/gorakhargosh/watchdog/pull/1055>`__)
Expand Down
39 changes: 27 additions & 12 deletions src/watchdog/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@
:members:
:show-inheritance:
.. autoclass:: FileClosedNoWriteEvent
:members:
:show-inheritance:
.. autoclass:: FileOpenedEvent
:members:
:show-inheritance:
Expand Down Expand Up @@ -96,7 +100,6 @@
import os.path
import re
from dataclasses import dataclass, field
from typing import ClassVar

from watchdog.utils.patterns import match_any_paths

Expand All @@ -105,6 +108,7 @@
EVENT_TYPE_CREATED = "created"
EVENT_TYPE_MODIFIED = "modified"
EVENT_TYPE_CLOSED = "closed"
EVENT_TYPE_CLOSED_NO_WRITE = "closed_no_write"
EVENT_TYPE_OPENED = "opened"


Expand Down Expand Up @@ -167,6 +171,12 @@ class FileClosedEvent(FileSystemEvent):
event_type = EVENT_TYPE_CLOSED


class FileClosedNoWriteEvent(FileSystemEvent):
"""File system event representing an unmodified file close on the file system."""

event_type = EVENT_TYPE_CLOSED_NO_WRITE


class FileOpenedEvent(FileSystemEvent):
"""File system event representing file close on the file system."""

Expand Down Expand Up @@ -206,15 +216,6 @@ class DirMovedEvent(FileSystemMovedEvent):
class FileSystemEventHandler:
"""Base file system event handler that you can override methods from."""

dispatch_table: ClassVar = {
EVENT_TYPE_CREATED: "on_created",
EVENT_TYPE_DELETED: "on_deleted",
EVENT_TYPE_MODIFIED: "on_modified",
EVENT_TYPE_MOVED: "on_moved",
EVENT_TYPE_CLOSED: "on_closed",
EVENT_TYPE_OPENED: "on_opened",
}

def dispatch(self, event: FileSystemEvent) -> None:
"""Dispatches events to the appropriate methods.
Expand All @@ -224,7 +225,7 @@ def dispatch(self, event: FileSystemEvent) -> None:
:class:`FileSystemEvent`
"""
self.on_any_event(event)
getattr(self, self.dispatch_table[event.event_type])(event)
getattr(self, f"on_{event.event_type}")(event)

def on_any_event(self, event: FileSystemEvent) -> None:
"""Catch-all event handler.
Expand Down Expand Up @@ -280,6 +281,15 @@ def on_closed(self, event: FileClosedEvent) -> None:
:class:`FileClosedEvent`
"""

def on_closed_no_write(self, event: FileClosedNoWriteEvent) -> None:
"""Called when a file opened for reading is closed.
:param event:
Event representing file closing.
:type event:
:class:`FileClosedNoWriteEvent`
"""

def on_opened(self, event: FileOpenedEvent) -> None:
"""Called when a file is opened.
Expand Down Expand Up @@ -483,7 +493,12 @@ def on_modified(self, event: DirModifiedEvent | FileModifiedEvent) -> None:
def on_closed(self, event: FileClosedEvent) -> None:
super().on_closed(event)

self.logger.info("Closed file: %s", event.src_path)
self.logger.info("Closed modified file: %s", event.src_path)

def on_closed_no_write(self, event: FileClosedNoWriteEvent) -> None:
super().on_closed_no_write(event)

self.logger.info("Closed read file: %s", event.src_path)

def on_opened(self, event: FileOpenedEvent) -> None:
super().on_opened(event)
Expand Down
44 changes: 15 additions & 29 deletions src/watchdog/observers/inotify.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
DirModifiedEvent,
DirMovedEvent,
FileClosedEvent,
FileClosedNoWriteEvent,
FileCreatedEvent,
FileDeletedEvent,
FileModifiedEvent,
Expand Down Expand Up @@ -181,23 +182,25 @@ def queue_events(self, timeout, *, full_events=False):
cls = DirCreatedEvent if event.is_directory else FileCreatedEvent
self.queue_event(cls(src_path))
self.queue_event(DirModifiedEvent(os.path.dirname(src_path)))
elif event.is_close_write and not event.is_directory:
cls = FileClosedEvent
self.queue_event(cls(src_path))
self.queue_event(DirModifiedEvent(os.path.dirname(src_path)))
elif event.is_open and not event.is_directory:
cls = FileOpenedEvent
self.queue_event(cls(src_path))
elif event.is_delete_self and src_path == self.watch.path:
cls = DirDeletedEvent if event.is_directory else FileDeletedEvent
self.queue_event(cls(src_path))
self.stop()
elif not event.is_directory:
if event.is_open:
cls = FileOpenedEvent
self.queue_event(cls(src_path))
elif event.is_close_write:
cls = FileClosedEvent
self.queue_event(cls(src_path))
self.queue_event(DirModifiedEvent(os.path.dirname(src_path)))
elif event.is_close_nowrite:
cls = FileClosedNoWriteEvent
self.queue_event(cls(src_path))

def _decode_path(self, path):
"""Decode path only if unicode string was passed to this emitter."""
if isinstance(self.watch.path, bytes):
return path
return os.fsdecode(path)
return path if isinstance(self.watch.path, bytes) else os.fsdecode(path)

def get_event_mask_from_filter(self):
"""Optimization: Only include events we are filtering in inotify call"""
Expand Down Expand Up @@ -225,6 +228,8 @@ def get_event_mask_from_filter(self):
event_mask |= InotifyConstants.IN_DELETE
elif cls is FileClosedEvent:
event_mask |= InotifyConstants.IN_CLOSE
elif cls is FileClosedNoWriteEvent:
event_mask |= InotifyConstants.IN_CLOSE_NOWRITE
elif cls is FileOpenedEvent:
event_mask |= InotifyConstants.IN_OPEN
return event_mask
Expand All @@ -233,27 +238,8 @@ def get_event_mask_from_filter(self):
class InotifyFullEmitter(InotifyEmitter):
"""inotify(7)-based event emitter. By default this class produces move events even if they are not matched
Such move events will have a ``None`` value for the unmatched part.
:param event_queue:
The event queue to fill with events.
:param watch:
A watch object representing the directory to monitor.
:type watch:
:class:`watchdog.observers.api.ObservedWatch`
:param timeout:
Read events blocking timeout (in seconds).
:type timeout:
``float``
:param event_filter:
Collection of event types to emit, or None for no filtering (default).
:type event_filter:
Iterable[:class:`watchdog.events.FileSystemEvent`] | None
"""

def __init__(self, event_queue, watch, *, timeout=DEFAULT_EMITTER_TIMEOUT, event_filter=None):
super().__init__(event_queue, watch, timeout=timeout, event_filter=event_filter)

def queue_events(self, timeout, *, events=True):
InotifyEmitter.queue_events(self, timeout, full_events=events)

Expand Down
1 change: 1 addition & 0 deletions src/watchdog/observers/inotify_c.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ class InotifyConstants:
InotifyConstants.IN_DELETE_SELF,
InotifyConstants.IN_DONT_FOLLOW,
InotifyConstants.IN_CLOSE_WRITE,
InotifyConstants.IN_CLOSE_NOWRITE,
InotifyConstants.IN_OPEN,
],
)
Expand Down
6 changes: 3 additions & 3 deletions src/watchdog/tricks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
import threading
import time

from watchdog.events import EVENT_TYPE_OPENED, FileSystemEvent, PatternMatchingEventHandler
from watchdog.events import EVENT_TYPE_CLOSED_NO_WRITE, EVENT_TYPE_OPENED, FileSystemEvent, PatternMatchingEventHandler
from watchdog.utils import echo, platform
from watchdog.utils.event_debouncer import EventDebouncer
from watchdog.utils.process_watcher import ProcessWatcher
Expand Down Expand Up @@ -111,7 +111,7 @@ def __init__(
self._process_watchers = set()

def on_any_event(self, event: FileSystemEvent) -> None:
if event.event_type == EVENT_TYPE_OPENED:
if event.event_type in {EVENT_TYPE_OPENED, EVENT_TYPE_CLOSED_NO_WRITE}:
# FIXME: see issue #949, and find a way to better handle that scenario
return

Expand Down Expand Up @@ -277,7 +277,7 @@ def _stop_process(self) -> None:

@echo_events
def on_any_event(self, event: FileSystemEvent) -> None:
if event.event_type == EVENT_TYPE_OPENED:
if event.event_type in {EVENT_TYPE_OPENED, EVENT_TYPE_CLOSED_NO_WRITE}:
# FIXME: see issue #949, and find a way to better handle that scenario
return

Expand Down
16 changes: 12 additions & 4 deletions tests/test_emitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
DirModifiedEvent,
DirMovedEvent,
FileClosedEvent,
FileClosedNoWriteEvent,
FileCreatedEvent,
FileDeletedEvent,
FileModifiedEvent,
Expand Down Expand Up @@ -75,10 +76,9 @@ def test_create(p: P, event_queue: TestEventQueue, start_watching: StartWatching
assert isinstance(event, FileClosedEvent)


@pytest.mark.xfail(reason="known to be problematic")
@pytest.mark.skipif(not platform.is_linux(), reason="FileCloseEvent only supported in GNU/Linux")
@pytest.mark.skipif(not platform.is_linux(), reason="FileClosed*Event 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_closed(p: P, event_queue: TestEventQueue, start_watching: StartWatching) -> None:
with open(p("a"), "a"):
start_watching()

Expand All @@ -91,9 +91,17 @@ def test_close(p: P, event_queue: TestEventQueue, start_watching: StartWatching)
assert os.path.normpath(event.src_path) == os.path.normpath(p(""))
assert isinstance(event, DirModifiedEvent)

# After read-only, only IN_CLOSE_NOWRITE is emitted but not caught for now #747
# After read-only, only IN_CLOSE_NOWRITE is emitted
open(p("a")).close()

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, FileClosedNoWriteEvent)

assert event_queue.empty()


Expand Down
36 changes: 35 additions & 1 deletion tests/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

from watchdog.events import (
EVENT_TYPE_CLOSED,
EVENT_TYPE_CLOSED_NO_WRITE,
EVENT_TYPE_CREATED,
EVENT_TYPE_DELETED,
EVENT_TYPE_MODIFIED,
Expand All @@ -27,6 +28,7 @@
DirModifiedEvent,
DirMovedEvent,
FileClosedEvent,
FileClosedNoWriteEvent,
FileCreatedEvent,
FileDeletedEvent,
FileModifiedEvent,
Expand Down Expand Up @@ -94,6 +96,14 @@ def test_file_closed_event():
assert not event.is_synthetic


def test_file_closed_no_write_event():
event = FileClosedNoWriteEvent(path_1)
assert path_1 == event.src_path
assert event.event_type == EVENT_TYPE_CLOSED_NO_WRITE
assert not event.is_directory
assert not event.is_synthetic


def test_file_opened_event():
event = FileOpenedEvent(path_1)
assert path_1 == event.src_path
Expand Down Expand Up @@ -132,6 +142,7 @@ def test_file_system_event_handler_dispatch():
dir_cre_event = DirCreatedEvent("/path/blah.py")
file_cre_event = FileCreatedEvent("/path/blah.txt")
file_cls_event = FileClosedEvent("/path/blah.txt")
file_cls_nw_event = FileClosedNoWriteEvent("/path/blah.txt")
file_opened_event = FileOpenedEvent("/path/blah.txt")
dir_mod_event = DirModifiedEvent("/path/blah.py")
file_mod_event = FileModifiedEvent("/path/blah.txt")
Expand All @@ -148,29 +159,50 @@ def test_file_system_event_handler_dispatch():
file_cre_event,
file_mov_event,
file_cls_event,
file_cls_nw_event,
file_opened_event,
]

checkpoint = 0

class TestableEventHandler(FileSystemEventHandler):
def on_any_event(self, event):
pass
nonlocal checkpoint
checkpoint += 1

def on_modified(self, event):
nonlocal checkpoint
checkpoint += 1
assert event.event_type == EVENT_TYPE_MODIFIED

def on_deleted(self, event):
nonlocal checkpoint
checkpoint += 1
assert event.event_type == EVENT_TYPE_DELETED

def on_moved(self, event):
nonlocal checkpoint
checkpoint += 1
assert event.event_type == EVENT_TYPE_MOVED

def on_created(self, event):
nonlocal checkpoint
checkpoint += 1
assert event.event_type == EVENT_TYPE_CREATED

def on_closed(self, event):
nonlocal checkpoint
checkpoint += 1
assert event.event_type == EVENT_TYPE_CLOSED

def on_closed_no_write(self, event):
nonlocal checkpoint
checkpoint += 1
assert event.event_type == EVENT_TYPE_CLOSED_NO_WRITE

def on_opened(self, event):
nonlocal checkpoint
checkpoint += 1
assert event.event_type == EVENT_TYPE_OPENED

handler = TestableEventHandler()
Expand All @@ -179,6 +211,8 @@ def on_opened(self, event):
assert not event.is_synthetic
handler.dispatch(event)

assert checkpoint == len(all_events) * 2 # `on_any_event()` + specific `on_XXX()`


def test_event_comparison():
creation1 = FileCreatedEvent("foo")
Expand Down
11 changes: 3 additions & 8 deletions tests/test_inotify_buffer.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,11 @@ def test_move_internal_batch(p):
mv(p("dir1", f), p("dir2", f))

# Check that all n events are paired
i = 0
while i < n:
for _ in range(n):
frm, to = wait_for_move_event(inotify.read_event)
assert os.path.dirname(frm.src_path).endswith(b"/dir1")
assert os.path.dirname(to.src_path).endswith(b"/dir2")
assert frm.name == to.name
i += 1
inotify.close()


Expand Down Expand Up @@ -146,11 +144,8 @@ def delayed(*args, **kwargs):
class InotifyBufferDelayedRead(InotifyBuffer):
def run(self, *args, **kwargs):
# Introduce a delay to trigger the race condition where the file descriptor is
# closed prior to a read being triggered. Ignoring type concerns since we are
# intentionally doing something odd.
self._inotify.read_events = delay_call( # type: ignore[method-assign]
function=self._inotify.read_events, seconds=1
)
# closed prior to a read being triggered.
self._inotify.read_events = delay_call(self._inotify.read_events, 1)

return super().run(*args, **kwargs)

Expand Down
Loading

0 comments on commit b9230ac

Please sign in to comment.