Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[inotify] Add support for IN_CLOSE_NOWRITE events #1059

Merged
merged 2 commits into from
Aug 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 }}
2 changes: 2 additions & 0 deletions changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ 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>`__)
- [inotify] Removed the ``InotifyConstants.IN_CLOSE`` constant (`#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
46 changes: 16 additions & 30 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 All @@ -224,7 +227,9 @@ def get_event_mask_from_filter(self):
elif cls in (DirDeletedEvent, FileDeletedEvent):
event_mask |= InotifyConstants.IN_DELETE
elif cls is FileClosedEvent:
event_mask |= InotifyConstants.IN_CLOSE
event_mask |= InotifyConstants.IN_CLOSE_WRITE
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
8 changes: 2 additions & 6 deletions src/watchdog/observers/inotify_c.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ class InotifyConstants:
IN_MOVE_SELF = 0x00000800 # Self was moved.

# Helper user-space events.
IN_CLOSE = IN_CLOSE_WRITE | IN_CLOSE_NOWRITE # Close.
IN_MOVE = IN_MOVED_FROM | IN_MOVED_TO # Moves.

# Events sent by the kernel to a watch.
Expand Down Expand Up @@ -109,6 +108,7 @@ class InotifyConstants:
InotifyConstants.IN_DELETE_SELF,
InotifyConstants.IN_DONT_FOLLOW,
InotifyConstants.IN_CLOSE_WRITE,
InotifyConstants.IN_CLOSE_NOWRITE,
InotifyConstants.IN_OPEN,
],
)
Expand Down Expand Up @@ -551,11 +551,7 @@ def __hash__(self):
def _get_mask_string(mask):
masks = []
for c in dir(InotifyConstants):
if c.startswith("IN_") and c not in [
"IN_ALL_EVENTS",
"IN_CLOSE",
"IN_MOVE",
]:
if c.startswith("IN_") and c not in {"IN_ALL_EVENTS", "IN_MOVE"}:
c_val = getattr(InotifyConstants, c)
if mask & c_val:
masks.append(c)
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
Loading