340 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Python
		
	
	
	
			
		
		
	
	
			340 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Python
		
	
	
	
""":module: watchdog.observers.fsevents
 | 
						|
:synopsis: FSEvents based emitter implementation.
 | 
						|
:author: yesudeep@google.com (Yesudeep Mangalapilly)
 | 
						|
:author: contact@tiger-222.fr (Mickaël Schoentgen)
 | 
						|
:platforms: macOS
 | 
						|
"""
 | 
						|
 | 
						|
from __future__ import annotations
 | 
						|
 | 
						|
import logging
 | 
						|
import os
 | 
						|
import threading
 | 
						|
import time
 | 
						|
import unicodedata
 | 
						|
from typing import TYPE_CHECKING
 | 
						|
 | 
						|
import _watchdog_fsevents as _fsevents
 | 
						|
 | 
						|
from watchdog.events import (
 | 
						|
    DirCreatedEvent,
 | 
						|
    DirDeletedEvent,
 | 
						|
    DirModifiedEvent,
 | 
						|
    DirMovedEvent,
 | 
						|
    FileCreatedEvent,
 | 
						|
    FileDeletedEvent,
 | 
						|
    FileModifiedEvent,
 | 
						|
    FileMovedEvent,
 | 
						|
    generate_sub_created_events,
 | 
						|
    generate_sub_moved_events,
 | 
						|
)
 | 
						|
from watchdog.observers.api import DEFAULT_EMITTER_TIMEOUT, DEFAULT_OBSERVER_TIMEOUT, BaseObserver, EventEmitter
 | 
						|
from watchdog.utils.dirsnapshot import DirectorySnapshot
 | 
						|
 | 
						|
if TYPE_CHECKING:
 | 
						|
    from watchdog.events import FileSystemEvent, FileSystemEventHandler
 | 
						|
    from watchdog.observers.api import EventQueue, ObservedWatch
 | 
						|
 | 
						|
 | 
						|
logger = logging.getLogger("fsevents")
 | 
						|
 | 
						|
 | 
						|
class FSEventsEmitter(EventEmitter):
 | 
						|
    """macOS FSEvents Emitter class.
 | 
						|
 | 
						|
    :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).
 | 
						|
    :param event_filter:
 | 
						|
        Collection of event types to emit, or None for no filtering (default).
 | 
						|
    :param suppress_history:
 | 
						|
        The FSEvents API may emit historic events up to 30 sec before the watch was
 | 
						|
        started. When ``suppress_history`` is ``True``, those events will be suppressed
 | 
						|
        by creating a directory snapshot of the watched path before starting the stream
 | 
						|
        as a reference to suppress old events. Warning: This may result in significant
 | 
						|
        memory usage in case of a large number of items in the watched path.
 | 
						|
    :type timeout:
 | 
						|
        ``float``
 | 
						|
    """
 | 
						|
 | 
						|
    def __init__(
 | 
						|
        self,
 | 
						|
        event_queue: EventQueue,
 | 
						|
        watch: ObservedWatch,
 | 
						|
        *,
 | 
						|
        timeout: float = DEFAULT_EMITTER_TIMEOUT,
 | 
						|
        event_filter: list[type[FileSystemEvent]] | None = None,
 | 
						|
        suppress_history: bool = False,
 | 
						|
    ) -> None:
 | 
						|
        super().__init__(event_queue, watch, timeout=timeout, event_filter=event_filter)
 | 
						|
        self._fs_view: set[int] = set()
 | 
						|
        self.suppress_history = suppress_history
 | 
						|
        self._start_time = 0.0
 | 
						|
        self._starting_state: DirectorySnapshot | None = None
 | 
						|
        self._lock = threading.Lock()
 | 
						|
        self._absolute_watch_path = os.path.realpath(os.path.abspath(os.path.expanduser(self.watch.path)))
 | 
						|
 | 
						|
    def on_thread_stop(self) -> None:
 | 
						|
        _fsevents.remove_watch(self.watch)
 | 
						|
        _fsevents.stop(self)
 | 
						|
 | 
						|
    def queue_event(self, event: FileSystemEvent) -> None:
 | 
						|
        # fsevents defaults to be recursive, so if the watch was meant to be non-recursive then we need to drop
 | 
						|
        # all the events here which do not have a src_path / dest_path that matches the watched path
 | 
						|
        if self._watch.is_recursive or not self._is_recursive_event(event):
 | 
						|
            logger.debug("queue_event %s", event)
 | 
						|
            EventEmitter.queue_event(self, event)
 | 
						|
        else:
 | 
						|
            logger.debug("drop event %s", event)
 | 
						|
 | 
						|
    def _is_recursive_event(self, event: FileSystemEvent) -> bool:
 | 
						|
        src_path = event.src_path if event.is_directory else os.path.dirname(event.src_path)
 | 
						|
        if src_path == self._absolute_watch_path:
 | 
						|
            return False
 | 
						|
 | 
						|
        if isinstance(event, (FileMovedEvent, DirMovedEvent)):
 | 
						|
            # when moving something into the watch path we must always take the dirname,
 | 
						|
            # otherwise we miss out on `DirMovedEvent`s
 | 
						|
            dest_path = os.path.dirname(event.dest_path)
 | 
						|
            if dest_path == self._absolute_watch_path:
 | 
						|
                return False
 | 
						|
 | 
						|
        return True
 | 
						|
 | 
						|
    def _queue_created_event(self, event: FileSystemEvent, src_path: bytes | str, dirname: bytes | str) -> None:
 | 
						|
        cls = DirCreatedEvent if event.is_directory else FileCreatedEvent
 | 
						|
        self.queue_event(cls(src_path))
 | 
						|
        self.queue_event(DirModifiedEvent(dirname))
 | 
						|
 | 
						|
    def _queue_deleted_event(self, event: FileSystemEvent, src_path: bytes | str, dirname: bytes | str) -> None:
 | 
						|
        cls = DirDeletedEvent if event.is_directory else FileDeletedEvent
 | 
						|
        self.queue_event(cls(src_path))
 | 
						|
        self.queue_event(DirModifiedEvent(dirname))
 | 
						|
 | 
						|
    def _queue_modified_event(self, event: FileSystemEvent, src_path: bytes | str, dirname: bytes | str) -> None:
 | 
						|
        cls = DirModifiedEvent if event.is_directory else FileModifiedEvent
 | 
						|
        self.queue_event(cls(src_path))
 | 
						|
 | 
						|
    def _queue_renamed_event(
 | 
						|
        self,
 | 
						|
        src_event: FileSystemEvent,
 | 
						|
        src_path: bytes | str,
 | 
						|
        dst_path: bytes | str,
 | 
						|
        src_dirname: bytes | str,
 | 
						|
        dst_dirname: bytes | str,
 | 
						|
    ) -> None:
 | 
						|
        cls = DirMovedEvent if src_event.is_directory else FileMovedEvent
 | 
						|
        dst_path = self._encode_path(dst_path)
 | 
						|
        self.queue_event(cls(src_path, dst_path))
 | 
						|
        self.queue_event(DirModifiedEvent(src_dirname))
 | 
						|
        self.queue_event(DirModifiedEvent(dst_dirname))
 | 
						|
 | 
						|
    def _is_historic_created_event(self, event: _fsevents.NativeEvent) -> bool:
 | 
						|
        # We only queue a created event if the item was created after we
 | 
						|
        # started the FSEventsStream.
 | 
						|
 | 
						|
        in_history = event.inode in self._fs_view
 | 
						|
 | 
						|
        if self._starting_state:
 | 
						|
            try:
 | 
						|
                old_inode = self._starting_state.inode(event.path)[0]
 | 
						|
                before_start = old_inode == event.inode
 | 
						|
            except KeyError:
 | 
						|
                before_start = False
 | 
						|
        else:
 | 
						|
            before_start = False
 | 
						|
 | 
						|
        return in_history or before_start
 | 
						|
 | 
						|
    @staticmethod
 | 
						|
    def _is_meta_mod(event: _fsevents.NativeEvent) -> bool:
 | 
						|
        """Returns True if the event indicates a change in metadata."""
 | 
						|
        return event.is_inode_meta_mod or event.is_xattr_mod or event.is_owner_change
 | 
						|
 | 
						|
    def queue_events(self, timeout: float, events: list[_fsevents.NativeEvent]) -> None:  # type: ignore[override]
 | 
						|
        if logger.getEffectiveLevel() <= logging.DEBUG:
 | 
						|
            for event in events:
 | 
						|
                flags = ", ".join(attr for attr in dir(event) if getattr(event, attr) is True)
 | 
						|
                logger.debug("%s: %s", event, flags)
 | 
						|
 | 
						|
        if time.monotonic() - self._start_time > 60:
 | 
						|
            # Event history is no longer needed, let's free some memory.
 | 
						|
            self._starting_state = None
 | 
						|
 | 
						|
        while events:
 | 
						|
            event = events.pop(0)
 | 
						|
 | 
						|
            src_path = self._encode_path(event.path)
 | 
						|
            src_dirname = os.path.dirname(src_path)
 | 
						|
 | 
						|
            try:
 | 
						|
                stat = os.stat(src_path)
 | 
						|
            except OSError:
 | 
						|
                stat = None
 | 
						|
 | 
						|
            exists = stat and stat.st_ino == event.inode
 | 
						|
 | 
						|
            # FSevents may coalesce multiple events for the same item + path into a
 | 
						|
            # single event. However, events are never coalesced for different items at
 | 
						|
            # the same path or for the same item at different paths. Therefore, the
 | 
						|
            # event chains "removed -> created" and "created -> renamed -> removed" will
 | 
						|
            # never emit a single native event and a deleted event *always* means that
 | 
						|
            # the item no longer existed at the end of the event chain.
 | 
						|
 | 
						|
            # Some events will have a spurious `is_created` flag set, coalesced from an
 | 
						|
            # already emitted and processed CreatedEvent. To filter those, we keep track
 | 
						|
            # of all inodes which we know to be already created. This is safer than
 | 
						|
            # keeping track of paths since paths are more likely to be reused than
 | 
						|
            # inodes.
 | 
						|
 | 
						|
            # Likewise, some events will have a spurious `is_modified`,
 | 
						|
            # `is_inode_meta_mod` or `is_xattr_mod` flag set. We currently do not
 | 
						|
            # suppress those but could do so if the item still exists by caching the
 | 
						|
            # stat result and verifying that it did change.
 | 
						|
 | 
						|
            if event.is_created and event.is_removed:
 | 
						|
                # Events will only be coalesced for the same item / inode.
 | 
						|
                # The sequence deleted -> created therefore cannot occur.
 | 
						|
                # Any combination with renamed cannot occur either.
 | 
						|
 | 
						|
                if not self._is_historic_created_event(event):
 | 
						|
                    self._queue_created_event(event, src_path, src_dirname)
 | 
						|
 | 
						|
                self._fs_view.add(event.inode)
 | 
						|
 | 
						|
                if event.is_modified or self._is_meta_mod(event):
 | 
						|
                    self._queue_modified_event(event, src_path, src_dirname)
 | 
						|
 | 
						|
                self._queue_deleted_event(event, src_path, src_dirname)
 | 
						|
                self._fs_view.discard(event.inode)
 | 
						|
 | 
						|
            else:
 | 
						|
                if event.is_created and not self._is_historic_created_event(event):
 | 
						|
                    self._queue_created_event(event, src_path, src_dirname)
 | 
						|
 | 
						|
                self._fs_view.add(event.inode)
 | 
						|
 | 
						|
                if event.is_modified or self._is_meta_mod(event):
 | 
						|
                    self._queue_modified_event(event, src_path, src_dirname)
 | 
						|
 | 
						|
                if 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),
 | 
						|
                        None,
 | 
						|
                    )
 | 
						|
 | 
						|
                    if dst_event:
 | 
						|
                        # Item was moved within the watched folder.
 | 
						|
                        logger.debug("Destination event for rename is %s", dst_event)
 | 
						|
 | 
						|
                        dst_path = self._encode_path(dst_event.path)
 | 
						|
                        dst_dirname = os.path.dirname(dst_path)
 | 
						|
 | 
						|
                        self._queue_renamed_event(event, src_path, dst_path, src_dirname, dst_dirname)
 | 
						|
                        self._fs_view.add(event.inode)
 | 
						|
 | 
						|
                        for sub_moved_event in generate_sub_moved_events(src_path, dst_path):
 | 
						|
                            self.queue_event(sub_moved_event)
 | 
						|
 | 
						|
                        # Process any coalesced flags for the dst_event.
 | 
						|
 | 
						|
                        events.remove(dst_event)
 | 
						|
 | 
						|
                        if dst_event.is_modified or self._is_meta_mod(dst_event):
 | 
						|
                            self._queue_modified_event(dst_event, dst_path, dst_dirname)
 | 
						|
 | 
						|
                        if dst_event.is_removed:
 | 
						|
                            self._queue_deleted_event(dst_event, dst_path, dst_dirname)
 | 
						|
                            self._fs_view.discard(dst_event.inode)
 | 
						|
 | 
						|
                    elif exists:
 | 
						|
                        # This is the destination event, item was moved into the watched
 | 
						|
                        # folder.
 | 
						|
                        self._queue_created_event(event, src_path, src_dirname)
 | 
						|
                        self._fs_view.add(event.inode)
 | 
						|
 | 
						|
                        for sub_created_event in generate_sub_created_events(src_path):
 | 
						|
                            self.queue_event(sub_created_event)
 | 
						|
 | 
						|
                    else:
 | 
						|
                        # This is the source event, item was moved out of the watched
 | 
						|
                        # folder.
 | 
						|
                        self._queue_deleted_event(event, src_path, src_dirname)
 | 
						|
                        self._fs_view.discard(event.inode)
 | 
						|
 | 
						|
                        # Skip further coalesced processing.
 | 
						|
                        continue
 | 
						|
 | 
						|
                if event.is_removed:
 | 
						|
                    # Won't occur together with renamed.
 | 
						|
                    self._queue_deleted_event(event, src_path, src_dirname)
 | 
						|
                    self._fs_view.discard(event.inode)
 | 
						|
 | 
						|
            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.debug("Stopping because root path was changed")
 | 
						|
                self.stop()
 | 
						|
 | 
						|
                self._fs_view.clear()
 | 
						|
 | 
						|
    def events_callback(self, paths: list[bytes], inodes: list[int], flags: list[int], ids: list[int]) -> None:
 | 
						|
        """Callback passed to FSEventStreamCreate(), it will receive all
 | 
						|
        FS events and queue them.
 | 
						|
        """
 | 
						|
        cls = _fsevents.NativeEvent
 | 
						|
        try:
 | 
						|
            events = [
 | 
						|
                cls(path, inode, event_flags, event_id)
 | 
						|
                for path, inode, event_flags, event_id in zip(paths, inodes, flags, ids)
 | 
						|
            ]
 | 
						|
            with self._lock:
 | 
						|
                self.queue_events(self.timeout, events)
 | 
						|
        except Exception:
 | 
						|
            logger.exception("Unhandled exception in fsevents callback")
 | 
						|
 | 
						|
    def run(self) -> None:
 | 
						|
        self.pathnames = [self.watch.path]
 | 
						|
        self._start_time = time.monotonic()
 | 
						|
        try:
 | 
						|
            _fsevents.add_watch(self, self.watch, self.events_callback, self.pathnames)
 | 
						|
            _fsevents.read_events(self)
 | 
						|
        except Exception:
 | 
						|
            logger.exception("Unhandled exception in FSEventsEmitter")
 | 
						|
 | 
						|
    def on_thread_start(self) -> None:
 | 
						|
        if self.suppress_history:
 | 
						|
            watch_path = os.fsdecode(self.watch.path) if isinstance(self.watch.path, bytes) else self.watch.path
 | 
						|
            self._starting_state = DirectorySnapshot(watch_path)
 | 
						|
 | 
						|
    def _encode_path(self, path: bytes | str) -> bytes | str:
 | 
						|
        """Encode path only if bytes were passed to this emitter."""
 | 
						|
        return os.fsencode(path) if isinstance(self.watch.path, bytes) else path
 | 
						|
 | 
						|
 | 
						|
class FSEventsObserver(BaseObserver):
 | 
						|
    def __init__(self, *, timeout: float = DEFAULT_OBSERVER_TIMEOUT) -> None:
 | 
						|
        super().__init__(FSEventsEmitter, timeout=timeout)
 | 
						|
 | 
						|
    def schedule(
 | 
						|
        self,
 | 
						|
        event_handler: FileSystemEventHandler,
 | 
						|
        path: str,
 | 
						|
        *,
 | 
						|
        recursive: bool = False,
 | 
						|
        event_filter: list[type[FileSystemEvent]] | None = None,
 | 
						|
    ) -> ObservedWatch:
 | 
						|
        # Fix for issue #26: Trace/BPT error when given a unicode path
 | 
						|
        # string. https://github.com/gorakhargosh/watchdog/issues#issue/26
 | 
						|
        if isinstance(path, str):
 | 
						|
            path = unicodedata.normalize("NFC", path)
 | 
						|
 | 
						|
        return super().schedule(event_handler, path, recursive=recursive, event_filter=event_filter)
 |