Removed IPC; added watchdog

This commit is contained in:
itdominator 2023-09-18 16:03:00 -05:00
parent 109877d3b0
commit 8e2ae773b6
32 changed files with 6415 additions and 130 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 409 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 363 KiB

After

Width:  |  Height:  |  Size: 192 KiB

View File

@ -6,7 +6,6 @@ import os
# Application imports # Application imports
from utils.debugging import debug_signal_handler from utils.debugging import debug_signal_handler
from utils.ipc_server import IPCServer
from core.window import Window from core.window import Window
@ -15,26 +14,12 @@ class AppLaunchException(Exception):
... ...
class Application(IPCServer): class Application:
""" docstring for Application. """ """ docstring for Application. """
def __init__(self, args, unknownargs): def __init__(self, args, unknownargs):
super(Application, self).__init__() super(Application, self).__init__()
if not settings_manager.is_trace_debug():
try:
self.create_ipc_listener()
except Exception:
...
if not self.is_ipc_alive:
for arg in unknownargs + [args.new_tab,]:
if os.path.isfile(arg):
message = f"FILE|{arg}"
self.send_ipc_message(message)
raise AppLaunchException(f"{app_name} IPC Server Exists: Will send path(s) to it and close...")
try: try:
# kill -SIGUSR2 <pid> from Linux/Unix or SIGBREAK signal from Windows # kill -SIGUSR2 <pid> from Linux/Unix or SIGBREAK signal from Windows
signal.signal( signal.signal(

View File

@ -6,6 +6,7 @@
from .mixins.processor_mixin import ProcessorMixin from .mixins.processor_mixin import ProcessorMixin
from .controller_data import ControllerData from .controller_data import ControllerData
from .widgets.desktop_files import DdesktopFiles from .widgets.desktop_files import DdesktopFiles
from .widgets.dir_watcher import DirWatcher
from .widgets.menu import Menu from .widgets.menu import Menu
@ -32,4 +33,5 @@ class Controller(ProcessorMixin, ControllerData):
def _load_widgets(self, args, unknownargs): def _load_widgets(self, args, unknownargs):
DdesktopFiles() DdesktopFiles()
DirWatcher()
Menu(args, unknownargs) Menu(args, unknownargs)

View File

@ -0,0 +1,58 @@
# Python imports
# Lib imports
from libs.watchdog.observers import Observer
from libs.watchdog.events import FileSystemEventHandler
# Application imports
class ShellmenFSWatcher(FileSystemEventHandler):
"""docstring for ShellmenFSWatcher."""
def __init__(self):
super(ShellmenFSWatcher, self).__init__()
def on_any_event(self, event):
if not event.event_type in ["opened", "closed"]:
event_system.emit("reload_desktop_entries")
class DirWatcher:
def __init__(self):
self.application_dirs = settings.config.application_dirs
self._setup_styling()
self._setup_signals()
self._subscribe_to_events()
self._load_widgets()
def _setup_styling(self):
...
def _setup_signals(self):
...
def _subscribe_to_events(self):
...
def _load_widgets(self):
for path in self.application_dirs:
self.create_watcher(path)
def create_watcher(self, path):
event_handler = ShellmenFSWatcher()
observer = Observer()
observer.schedule(event_handler, path, recursive = False)
observer.start()
# try:
# while True:
# time.sleep(1)
# finally:
# observer.stop()
# observer.join()

View File

@ -0,0 +1,14 @@
# Copyright 2011 Yesudeep Mangalapilly <yesudeep@gmail.com>
# Copyright 2012 Google, Inc & contributors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

599
src/libs/watchdog/events.py Executable file
View File

@ -0,0 +1,599 @@
# Copyright 2011 Yesudeep Mangalapilly <yesudeep@gmail.com>
# Copyright 2012 Google, Inc & contributors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
:module: libs.watchdog.events
:synopsis: File system events and event handlers.
:author: yesudeep@google.com (Yesudeep Mangalapilly)
:author: contact@tiger-222.fr (Mickaël Schoentgen)
Event Classes
-------------
.. autoclass:: FileSystemEvent
:members:
:show-inheritance:
:inherited-members:
.. autoclass:: FileSystemMovedEvent
:members:
:show-inheritance:
.. autoclass:: FileMovedEvent
:members:
:show-inheritance:
.. autoclass:: DirMovedEvent
:members:
:show-inheritance:
.. autoclass:: FileModifiedEvent
:members:
:show-inheritance:
.. autoclass:: DirModifiedEvent
:members:
:show-inheritance:
.. autoclass:: FileCreatedEvent
:members:
:show-inheritance:
.. autoclass:: FileClosedEvent
:members:
:show-inheritance:
.. autoclass:: FileOpenedEvent
:members:
:show-inheritance:
.. autoclass:: DirCreatedEvent
:members:
:show-inheritance:
.. autoclass:: FileDeletedEvent
:members:
:show-inheritance:
.. autoclass:: DirDeletedEvent
:members:
:show-inheritance:
Event Handler Classes
---------------------
.. autoclass:: FileSystemEventHandler
:members:
:show-inheritance:
.. autoclass:: PatternMatchingEventHandler
:members:
:show-inheritance:
.. autoclass:: RegexMatchingEventHandler
:members:
:show-inheritance:
.. autoclass:: LoggingEventHandler
:members:
:show-inheritance:
"""
from __future__ import annotations
import logging
import os.path
import re
from libs.watchdog.utils.patterns import match_any_paths
EVENT_TYPE_MOVED = "moved"
EVENT_TYPE_DELETED = "deleted"
EVENT_TYPE_CREATED = "created"
EVENT_TYPE_MODIFIED = "modified"
EVENT_TYPE_CLOSED = "closed"
EVENT_TYPE_OPENED = "opened"
class FileSystemEvent:
"""
Immutable type that represents a file system event that is triggered
when a change occurs on the monitored file system.
All FileSystemEvent objects are required to be immutable and hence
can be used as keys in dictionaries or be added to sets.
"""
event_type = ""
"""The type of the event as a string."""
is_directory = False
"""True if event was emitted for a directory; False otherwise."""
is_synthetic = False
"""
True if event was synthesized; False otherwise.
These are events that weren't actually broadcast by the OS, but
are presumed to have happened based on other, actual events.
"""
def __init__(self, src_path):
self._src_path = src_path
@property
def src_path(self):
"""Source path of the file system object that triggered this event."""
return self._src_path
def __str__(self):
return self.__repr__()
def __repr__(self):
return (
f"<{type(self).__name__}: event_type={self.event_type}, "
f"src_path={self.src_path!r}, is_directory={self.is_directory}>"
)
# Used for comparison of events.
@property
def key(self):
return (self.event_type, self.src_path, self.is_directory)
def __eq__(self, event):
return self.key == event.key
def __ne__(self, event):
return self.key != event.key
def __hash__(self):
return hash(self.key)
class FileSystemMovedEvent(FileSystemEvent):
"""
File system event representing any kind of file system movement.
"""
event_type = EVENT_TYPE_MOVED
def __init__(self, src_path, dest_path):
super().__init__(src_path)
self._dest_path = dest_path
@property
def dest_path(self):
"""The destination path of the move event."""
return self._dest_path
# Used for hashing this as an immutable object.
@property
def key(self):
return (self.event_type, self.src_path, self.dest_path, self.is_directory)
def __repr__(self):
return (
f"<{type(self).__name__}: src_path={self.src_path!r}, "
f"dest_path={self.dest_path!r}, is_directory={self.is_directory}>"
)
# File events.
class FileDeletedEvent(FileSystemEvent):
"""File system event representing file deletion on the file system."""
event_type = EVENT_TYPE_DELETED
class FileModifiedEvent(FileSystemEvent):
"""File system event representing file modification on the file system."""
event_type = EVENT_TYPE_MODIFIED
class FileCreatedEvent(FileSystemEvent):
"""File system event representing file creation on the file system."""
event_type = EVENT_TYPE_CREATED
class FileMovedEvent(FileSystemMovedEvent):
"""File system event representing file movement on the file system."""
class FileClosedEvent(FileSystemEvent):
"""File system event representing file close on the file system."""
event_type = EVENT_TYPE_CLOSED
class FileOpenedEvent(FileSystemEvent):
"""File system event representing file close on the file system."""
event_type = EVENT_TYPE_OPENED
# Directory events.
class DirDeletedEvent(FileSystemEvent):
"""File system event representing directory deletion on the file system."""
event_type = EVENT_TYPE_DELETED
is_directory = True
class DirModifiedEvent(FileSystemEvent):
"""
File system event representing directory modification on the file system.
"""
event_type = EVENT_TYPE_MODIFIED
is_directory = True
class DirCreatedEvent(FileSystemEvent):
"""File system event representing directory creation on the file system."""
event_type = EVENT_TYPE_CREATED
is_directory = True
class DirMovedEvent(FileSystemMovedEvent):
"""File system event representing directory movement on the file system."""
is_directory = True
class FileSystemEventHandler:
"""
Base file system event handler that you can override methods from.
"""
def dispatch(self, event):
"""Dispatches events to the appropriate methods.
:param event:
The event object representing the file system event.
:type event:
:class:`FileSystemEvent`
"""
self.on_any_event(event)
{
EVENT_TYPE_CREATED: self.on_created,
EVENT_TYPE_DELETED: self.on_deleted,
EVENT_TYPE_MODIFIED: self.on_modified,
EVENT_TYPE_MOVED: self.on_moved,
EVENT_TYPE_CLOSED: self.on_closed,
EVENT_TYPE_OPENED: self.on_opened,
}[event.event_type](event)
def on_any_event(self, event):
"""Catch-all event handler.
:param event:
The event object representing the file system event.
:type event:
:class:`FileSystemEvent`
"""
def on_moved(self, event):
"""Called when a file or a directory is moved or renamed.
:param event:
Event representing file/directory movement.
:type event:
:class:`DirMovedEvent` or :class:`FileMovedEvent`
"""
def on_created(self, event):
"""Called when a file or directory is created.
:param event:
Event representing file/directory creation.
:type event:
:class:`DirCreatedEvent` or :class:`FileCreatedEvent`
"""
def on_deleted(self, event):
"""Called when a file or directory is deleted.
:param event:
Event representing file/directory deletion.
:type event:
:class:`DirDeletedEvent` or :class:`FileDeletedEvent`
"""
def on_modified(self, event):
"""Called when a file or directory is modified.
:param event:
Event representing file/directory modification.
:type event:
:class:`DirModifiedEvent` or :class:`FileModifiedEvent`
"""
def on_closed(self, event):
"""Called when a file opened for writing is closed.
:param event:
Event representing file closing.
:type event:
:class:`FileClosedEvent`
"""
def on_opened(self, event):
"""Called when a file is opened.
:param event:
Event representing file opening.
:type event:
:class:`FileOpenedEvent`
"""
class PatternMatchingEventHandler(FileSystemEventHandler):
"""
Matches given patterns with file paths associated with occurring events.
"""
def __init__(
self,
patterns=None,
ignore_patterns=None,
ignore_directories=False,
case_sensitive=False,
):
super().__init__()
self._patterns = patterns
self._ignore_patterns = ignore_patterns
self._ignore_directories = ignore_directories
self._case_sensitive = case_sensitive
@property
def patterns(self):
"""
(Read-only)
Patterns to allow matching event paths.
"""
return self._patterns
@property
def ignore_patterns(self):
"""
(Read-only)
Patterns to ignore matching event paths.
"""
return self._ignore_patterns
@property
def ignore_directories(self):
"""
(Read-only)
``True`` if directories should be ignored; ``False`` otherwise.
"""
return self._ignore_directories
@property
def case_sensitive(self):
"""
(Read-only)
``True`` if path names should be matched sensitive to case; ``False``
otherwise.
"""
return self._case_sensitive
def dispatch(self, event):
"""Dispatches events to the appropriate methods.
:param event:
The event object representing the file system event.
:type event:
:class:`FileSystemEvent`
"""
if self.ignore_directories and event.is_directory:
return
paths = []
if hasattr(event, "dest_path"):
paths.append(os.fsdecode(event.dest_path))
if event.src_path:
paths.append(os.fsdecode(event.src_path))
if match_any_paths(
paths,
included_patterns=self.patterns,
excluded_patterns=self.ignore_patterns,
case_sensitive=self.case_sensitive,
):
super().dispatch(event)
class RegexMatchingEventHandler(FileSystemEventHandler):
"""
Matches given regexes with file paths associated with occurring events.
"""
def __init__(
self,
regexes=None,
ignore_regexes=None,
ignore_directories=False,
case_sensitive=False,
):
super().__init__()
if regexes is None:
regexes = [r".*"]
elif isinstance(regexes, str):
regexes = [regexes]
if ignore_regexes is None:
ignore_regexes = []
if case_sensitive:
self._regexes = [re.compile(r) for r in regexes]
self._ignore_regexes = [re.compile(r) for r in ignore_regexes]
else:
self._regexes = [re.compile(r, re.I) for r in regexes]
self._ignore_regexes = [re.compile(r, re.I) for r in ignore_regexes]
self._ignore_directories = ignore_directories
self._case_sensitive = case_sensitive
@property
def regexes(self):
"""
(Read-only)
Regexes to allow matching event paths.
"""
return self._regexes
@property
def ignore_regexes(self):
"""
(Read-only)
Regexes to ignore matching event paths.
"""
return self._ignore_regexes
@property
def ignore_directories(self):
"""
(Read-only)
``True`` if directories should be ignored; ``False`` otherwise.
"""
return self._ignore_directories
@property
def case_sensitive(self):
"""
(Read-only)
``True`` if path names should be matched sensitive to case; ``False``
otherwise.
"""
return self._case_sensitive
def dispatch(self, event):
"""Dispatches events to the appropriate methods.
:param event:
The event object representing the file system event.
:type event:
:class:`FileSystemEvent`
"""
if self.ignore_directories and event.is_directory:
return
paths = []
if hasattr(event, "dest_path"):
paths.append(os.fsdecode(event.dest_path))
if event.src_path:
paths.append(os.fsdecode(event.src_path))
if any(r.match(p) for r in self.ignore_regexes for p in paths):
return
if any(r.match(p) for r in self.regexes for p in paths):
super().dispatch(event)
class LoggingEventHandler(FileSystemEventHandler):
"""Logs all the events captured."""
def __init__(self, logger=None):
super().__init__()
self.logger = logger or logging.root
def on_moved(self, event):
super().on_moved(event)
what = "directory" if event.is_directory else "file"
self.logger.info(
"Moved %s: from %s to %s", what, event.src_path, event.dest_path
)
def on_created(self, event):
super().on_created(event)
what = "directory" if event.is_directory else "file"
self.logger.info("Created %s: %s", what, event.src_path)
def on_deleted(self, event):
super().on_deleted(event)
what = "directory" if event.is_directory else "file"
self.logger.info("Deleted %s: %s", what, event.src_path)
def on_modified(self, event):
super().on_modified(event)
what = "directory" if event.is_directory else "file"
self.logger.info("Modified %s: %s", what, event.src_path)
def generate_sub_moved_events(src_dir_path, dest_dir_path):
"""Generates an event list of :class:`DirMovedEvent` and
:class:`FileMovedEvent` objects for all the files and directories within
the given moved directory that were moved along with the directory.
:param src_dir_path:
The source path of the moved directory.
:param dest_dir_path:
The destination path of the moved directory.
:returns:
An iterable of file system events of type :class:`DirMovedEvent` and
:class:`FileMovedEvent`.
"""
for root, directories, filenames in os.walk(dest_dir_path):
for directory in directories:
full_path = os.path.join(root, directory)
renamed_path = (
full_path.replace(dest_dir_path, src_dir_path) if src_dir_path else None
)
dir_moved_event = DirMovedEvent(renamed_path, full_path)
dir_moved_event.is_synthetic = True
yield dir_moved_event
for filename in filenames:
full_path = os.path.join(root, filename)
renamed_path = (
full_path.replace(dest_dir_path, src_dir_path) if src_dir_path else None
)
file_moved_event = FileMovedEvent(renamed_path, full_path)
file_moved_event.is_synthetic = True
yield file_moved_event
def generate_sub_created_events(src_dir_path):
"""Generates an event list of :class:`DirCreatedEvent` and
:class:`FileCreatedEvent` objects for all the files and directories within
the given moved directory that were moved along with the directory.
:param src_dir_path:
The source path of the created directory.
:returns:
An iterable of file system events of type :class:`DirCreatedEvent` and
:class:`FileCreatedEvent`.
"""
for root, directories, filenames in os.walk(src_dir_path):
for directory in directories:
dir_created_event = DirCreatedEvent(os.path.join(root, directory))
dir_created_event.is_synthetic = True
yield dir_created_event
for filename in filenames:
file_created_event = FileCreatedEvent(os.path.join(root, filename))
file_created_event.is_synthetic = True
yield file_created_event

View File

@ -0,0 +1,97 @@
# Copyright 2011 Yesudeep Mangalapilly <yesudeep@gmail.com>
# Copyright 2012 Google, Inc & contributors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
:module: libs.watchdog.observers
:synopsis: Observer that picks a native implementation if available.
:author: yesudeep@google.com (Yesudeep Mangalapilly)
:author: contact@tiger-222.fr (Mickaël Schoentgen)
Classes
=======
.. autoclass:: Observer
:members:
:show-inheritance:
:inherited-members:
Observer thread that schedules watching directories and dispatches
calls to event handlers.
You can also import platform specific classes directly and use it instead
of :class:`Observer`. Here is a list of implemented observer classes.:
============== ================================ ==============================
Class Platforms Note
============== ================================ ==============================
|Inotify| Linux 2.6.13+ ``inotify(7)`` based observer
|FSEvents| macOS FSEvents based observer
|Kqueue| macOS and BSD with kqueue(2) ``kqueue(2)`` based observer
|WinApi| MS Windows Windows API-based observer
|Polling| Any fallback implementation
============== ================================ ==============================
.. |Inotify| replace:: :class:`.inotify.InotifyObserver`
.. |FSEvents| replace:: :class:`.fsevents.FSEventsObserver`
.. |Kqueue| replace:: :class:`.kqueue.KqueueObserver`
.. |WinApi| replace:: :class:`.read_directory_changes.WindowsApiObserver`
.. |Polling| replace:: :class:`.polling.PollingObserver`
"""
from __future__ import annotations
import sys
import warnings
from libs.watchdog.utils import UnsupportedLibc
from .api import BaseObserverSubclassCallable
Observer: BaseObserverSubclassCallable
if sys.platform.startswith("linux"):
try:
from .inotify import InotifyObserver as Observer
except UnsupportedLibc:
from .polling import PollingObserver as Observer
elif sys.platform.startswith("darwin"):
try:
from .fsevents import FSEventsObserver as Observer
except Exception:
try:
from .kqueue import KqueueObserver as Observer
warnings.warn("Failed to import fsevents. Fall back to kqueue")
except Exception:
from .polling import PollingObserver as Observer
warnings.warn("Failed to import fsevents and kqueue. Fall back to polling.")
elif sys.platform in ("dragonfly", "freebsd", "netbsd", "openbsd", "bsd"):
from .kqueue import KqueueObserver as Observer
elif sys.platform.startswith("win"):
try:
from .read_directory_changes import WindowsApiObserver as Observer
except Exception:
from .polling import PollingObserver as Observer
warnings.warn("Failed to import read_directory_changes. Fall back to polling.")
else:
from .polling import PollingObserver as Observer
__all__ = ["Observer"]

View File

@ -0,0 +1,386 @@
# Copyright 2011 Yesudeep Mangalapilly <yesudeep@gmail.com>
# Copyright 2012 Google, Inc & contributors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import queue
import threading
from pathlib import Path
from libs.watchdog.utils import BaseThread, Protocol
from libs.watchdog.utils.bricks import SkipRepeatsQueue
DEFAULT_EMITTER_TIMEOUT = 1 # in seconds.
DEFAULT_OBSERVER_TIMEOUT = 1 # in seconds.
# Collection classes
class EventQueue(SkipRepeatsQueue):
"""Thread-safe event queue based on a special queue that skips adding
the same event (:class:`FileSystemEvent`) multiple times consecutively.
Thus avoiding dispatching multiple event handling
calls when multiple identical events are produced quicker than an observer
can consume them.
"""
class ObservedWatch:
"""An scheduled watch.
:param path:
Path string.
:param recursive:
``True`` if watch is recursive; ``False`` otherwise.
"""
def __init__(self, path, recursive):
if isinstance(path, Path):
self._path = str(path)
else:
self._path = path
self._is_recursive = recursive
@property
def path(self):
"""The path that this watch monitors."""
return self._path
@property
def is_recursive(self):
"""Determines whether subdirectories are watched for the path."""
return self._is_recursive
@property
def key(self):
return self.path, self.is_recursive
def __eq__(self, watch):
return self.key == watch.key
def __ne__(self, watch):
return self.key != watch.key
def __hash__(self):
return hash(self.key)
def __repr__(self):
return f"<{type(self).__name__}: path={self.path!r}, is_recursive={self.is_recursive}>"
# Observer classes
class EventEmitter(BaseThread):
"""
Producer thread base class subclassed by event emitters
that generate events and populate a queue with them.
:param event_queue:
The event queue to populate with generated events.
:type event_queue:
:class:`libs.watchdog.events.EventQueue`
:param watch:
The watch to observe and produce events for.
:type watch:
:class:`ObservedWatch`
:param timeout:
Timeout (in seconds) between successive attempts at reading events.
:type timeout:
``float``
"""
def __init__(self, event_queue, watch, timeout=DEFAULT_EMITTER_TIMEOUT):
super().__init__()
self._event_queue = event_queue
self._watch = watch
self._timeout = timeout
@property
def timeout(self):
"""
Blocking timeout for reading events.
"""
return self._timeout
@property
def watch(self):
"""
The watch associated with this emitter.
"""
return self._watch
def queue_event(self, event):
"""
Queues a single event.
:param event:
Event to be queued.
:type event:
An instance of :class:`libs.watchdog.events.FileSystemEvent`
or a subclass.
"""
self._event_queue.put((event, self.watch))
def queue_events(self, timeout):
"""Override this method to populate the event queue with events
per interval period.
:param timeout:
Timeout (in seconds) between successive attempts at
reading events.
:type timeout:
``float``
"""
def run(self):
while self.should_keep_running():
self.queue_events(self.timeout)
class EventDispatcher(BaseThread):
"""
Consumer thread base class subclassed by event observer threads
that dispatch events from an event queue to appropriate event handlers.
:param timeout:
Timeout value (in seconds) passed to emitters
constructions in the child class BaseObserver.
:type timeout:
``float``
"""
_stop_event = object()
"""Event inserted into the queue to signal a requested stop."""
def __init__(self, timeout=DEFAULT_OBSERVER_TIMEOUT):
super().__init__()
self._event_queue = EventQueue()
self._timeout = timeout
@property
def timeout(self):
"""Timeout value to construct emitters with."""
return self._timeout
def stop(self):
BaseThread.stop(self)
try:
self.event_queue.put_nowait(EventDispatcher._stop_event)
except queue.Full:
pass
@property
def event_queue(self):
"""The event queue which is populated with file system events
by emitters and from which events are dispatched by a dispatcher
thread."""
return self._event_queue
def dispatch_events(self, event_queue):
"""Override this method to consume events from an event queue, blocking
on the queue for the specified timeout before raising :class:`queue.Empty`.
:param event_queue:
Event queue to populate with one set of events.
:type event_queue:
:class:`EventQueue`
:raises:
:class:`queue.Empty`
"""
def run(self):
while self.should_keep_running():
try:
self.dispatch_events(self.event_queue)
except queue.Empty:
continue
class BaseObserver(EventDispatcher):
"""Base observer."""
def __init__(self, emitter_class, timeout=DEFAULT_OBSERVER_TIMEOUT):
super().__init__(timeout)
self._emitter_class = emitter_class
self._lock = threading.RLock()
self._watches = set()
self._handlers = dict()
self._emitters = set()
self._emitter_for_watch = dict()
def _add_emitter(self, emitter):
self._emitter_for_watch[emitter.watch] = emitter
self._emitters.add(emitter)
def _remove_emitter(self, emitter):
del self._emitter_for_watch[emitter.watch]
self._emitters.remove(emitter)
emitter.stop()
try:
emitter.join()
except RuntimeError:
pass
def _clear_emitters(self):
for emitter in self._emitters:
emitter.stop()
for emitter in self._emitters:
try:
emitter.join()
except RuntimeError:
pass
self._emitters.clear()
self._emitter_for_watch.clear()
def _add_handler_for_watch(self, event_handler, watch):
if watch not in self._handlers:
self._handlers[watch] = set()
self._handlers[watch].add(event_handler)
def _remove_handlers_for_watch(self, watch):
del self._handlers[watch]
@property
def emitters(self):
"""Returns event emitter created by this observer."""
return self._emitters
def start(self):
for emitter in self._emitters.copy():
try:
emitter.start()
except Exception:
self._remove_emitter(emitter)
raise
super().start()
def schedule(self, event_handler, path, recursive=False):
"""
Schedules watching a path and calls appropriate methods specified
in the given event handler in response to file system events.
:param event_handler:
An event handler instance that has appropriate event handling
methods which will be called by the observer in response to
file system events.
:type event_handler:
:class:`libs.watchdog.events.FileSystemEventHandler` or a subclass
:param path:
Directory path that will be monitored.
:type path:
``str``
:param recursive:
``True`` if events will be emitted for sub-directories
traversed recursively; ``False`` otherwise.
:type recursive:
``bool``
:return:
An :class:`ObservedWatch` object instance representing
a watch.
"""
with self._lock:
watch = ObservedWatch(path, recursive)
self._add_handler_for_watch(event_handler, watch)
# If we don't have an emitter for this watch already, create it.
if self._emitter_for_watch.get(watch) is None:
emitter = self._emitter_class(
event_queue=self.event_queue, watch=watch, timeout=self.timeout
)
if self.is_alive():
emitter.start()
self._add_emitter(emitter)
self._watches.add(watch)
return watch
def add_handler_for_watch(self, event_handler, watch):
"""Adds a handler for the given watch.
:param event_handler:
An event handler instance that has appropriate event handling
methods which will be called by the observer in response to
file system events.
:type event_handler:
:class:`libs.watchdog.events.FileSystemEventHandler` or a subclass
:param watch:
The watch to add a handler for.
:type watch:
An instance of :class:`ObservedWatch` or a subclass of
:class:`ObservedWatch`
"""
with self._lock:
self._add_handler_for_watch(event_handler, watch)
def remove_handler_for_watch(self, event_handler, watch):
"""Removes a handler for the given watch.
:param event_handler:
An event handler instance that has appropriate event handling
methods which will be called by the observer in response to
file system events.
:type event_handler:
:class:`libs.watchdog.events.FileSystemEventHandler` or a subclass
:param watch:
The watch to remove a handler for.
:type watch:
An instance of :class:`ObservedWatch` or a subclass of
:class:`ObservedWatch`
"""
with self._lock:
self._handlers[watch].remove(event_handler)
def unschedule(self, watch):
"""Unschedules a watch.
:param watch:
The watch to unschedule.
:type watch:
An instance of :class:`ObservedWatch` or a subclass of
:class:`ObservedWatch`
"""
with self._lock:
emitter = self._emitter_for_watch[watch]
del self._handlers[watch]
self._remove_emitter(emitter)
self._watches.remove(watch)
def unschedule_all(self):
"""Unschedules all watches and detaches all associated event
handlers."""
with self._lock:
self._handlers.clear()
self._clear_emitters()
self._watches.clear()
def on_thread_stop(self):
self.unschedule_all()
def dispatch_events(self, event_queue):
entry = event_queue.get(block=True)
if entry is EventDispatcher._stop_event:
return
event, watch = entry
with self._lock:
# To allow unschedule/stop and safe removal of event handlers
# within event handlers itself, check if the handler is still
# registered after every dispatch.
for handler in list(self._handlers.get(watch, [])):
if handler in self._handlers.get(watch, []):
handler.dispatch(event)
event_queue.task_done()
class BaseObserverSubclassCallable(Protocol):
def __call__(self, timeout: float = ...) -> BaseObserver:
...

View File

@ -0,0 +1,354 @@
# Copyright 2011 Yesudeep Mangalapilly <yesudeep@gmail.com>
# Copyright 2012 Google, Inc & contributors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
:module: libs.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
import _libs.watchdog_fsevents as _fsevents # type: ignore[import]
from libs.watchdog.events import (
DirCreatedEvent,
DirDeletedEvent,
DirModifiedEvent,
DirMovedEvent,
FileCreatedEvent,
FileDeletedEvent,
FileModifiedEvent,
FileMovedEvent,
generate_sub_created_events,
generate_sub_moved_events,
)
from libs.watchdog.observers.api import DEFAULT_EMITTER_TIMEOUT, DEFAULT_OBSERVER_TIMEOUT, BaseObserver, EventEmitter
from libs.watchdog.utils.dirsnapshot import DirectorySnapshot
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:`libs.watchdog.observers.api.ObservedWatch`
:param timeout:
Read events blocking timeout (in seconds).
: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,
watch,
timeout=DEFAULT_EMITTER_TIMEOUT,
suppress_history=False,
):
super().__init__(event_queue, watch, timeout)
self._fs_view = set()
self.suppress_history = suppress_history
self._start_time = 0.0
self._starting_state = 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):
_fsevents.remove_watch(self.watch)
_fsevents.stop(self)
def queue_event(self, event):
# 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:
logger.debug("queue_event %s", event)
EventEmitter.queue_event(self, event)
else:
if 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):
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, src_path, dirname):
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, src_path, dirname):
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, src_path, dirname):
cls = DirModifiedEvent if event.is_directory else FileModifiedEvent
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)
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):
# 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):
"""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, events):
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(f"{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_event in generate_sub_moved_events(src_path, dst_path):
self.queue_event(sub_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_event in generate_sub_created_events(src_path):
self.queue_event(sub_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, inodes, flags, ids):
"""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):
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):
if self.suppress_history:
if isinstance(self.watch.path, bytes):
watch_path = os.fsdecode(self.watch.path)
else:
watch_path = self.watch.path
self._starting_state = DirectorySnapshot(watch_path)
def _encode_path(self, path):
"""Encode path only if bytes were passed to this emitter."""
if isinstance(self.watch.path, bytes):
return os.fsencode(path)
return path
class FSEventsObserver(BaseObserver):
def __init__(self, timeout=DEFAULT_OBSERVER_TIMEOUT):
super().__init__(emitter_class=FSEventsEmitter, timeout=timeout)
def schedule(self, event_handler, path, recursive=False):
# Fix for issue #26: Trace/BPT error when given a unicode path
# string. https://github.com/gorakhargosh/libs.watchdog/issues#issue/26
if isinstance(path, str):
path = unicodedata.normalize("NFC", path)
return BaseObserver.schedule(self, event_handler, path, recursive)

View File

@ -0,0 +1,262 @@
# Copyright 2014 Thomas Amland <thomas.amland@gmail.com>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
:module: libs.watchdog.observers.fsevents2
:synopsis: FSEvents based emitter implementation.
:platforms: macOS
"""
from __future__ import annotations
import logging
import os
import queue
import unicodedata
import warnings
from threading import Thread
from typing import List, Optional, Type
# pyobjc
import AppKit # type: ignore[import]
from FSEvents import ( # type: ignore[import]
CFRunLoopGetCurrent,
CFRunLoopRun,
CFRunLoopStop,
FSEventStreamCreate,
FSEventStreamInvalidate,
FSEventStreamRelease,
FSEventStreamScheduleWithRunLoop,
FSEventStreamStart,
FSEventStreamStop,
kCFAllocatorDefault,
kCFRunLoopDefaultMode,
kFSEventStreamCreateFlagFileEvents,
kFSEventStreamCreateFlagNoDefer,
kFSEventStreamEventFlagItemChangeOwner,
kFSEventStreamEventFlagItemCreated,
kFSEventStreamEventFlagItemFinderInfoMod,
kFSEventStreamEventFlagItemInodeMetaMod,
kFSEventStreamEventFlagItemIsDir,
kFSEventStreamEventFlagItemIsSymlink,
kFSEventStreamEventFlagItemModified,
kFSEventStreamEventFlagItemRemoved,
kFSEventStreamEventFlagItemRenamed,
kFSEventStreamEventFlagItemXattrMod,
kFSEventStreamEventIdSinceNow,
)
from libs.watchdog.events import (
DirCreatedEvent,
DirDeletedEvent,
DirModifiedEvent,
DirMovedEvent,
FileCreatedEvent,
FileDeletedEvent,
FileModifiedEvent,
FileMovedEvent,
FileSystemEvent,
)
from libs.watchdog.observers.api import DEFAULT_EMITTER_TIMEOUT, DEFAULT_OBSERVER_TIMEOUT, BaseObserver, EventEmitter
logger = logging.getLogger(__name__)
message = "libs.watchdog.observers.fsevents2 is deprecated and will be removed in a future release."
warnings.warn(message, DeprecationWarning)
logger.warning(message)
class FSEventsQueue(Thread):
"""Low level FSEvents client."""
def __init__(self, path):
Thread.__init__(self)
self._queue: queue.Queue[Optional[List[NativeEvent]]] = queue.Queue()
self._run_loop = None
if isinstance(path, bytes):
path = os.fsdecode(path)
self._path = unicodedata.normalize("NFC", path)
context = None
latency = 1.0
self._stream_ref = FSEventStreamCreate(
kCFAllocatorDefault,
self._callback,
context,
[self._path],
kFSEventStreamEventIdSinceNow,
latency,
kFSEventStreamCreateFlagNoDefer | kFSEventStreamCreateFlagFileEvents,
)
if self._stream_ref is None:
raise OSError("FSEvents. Could not create stream.")
def run(self):
pool = AppKit.NSAutoreleasePool.alloc().init()
self._run_loop = CFRunLoopGetCurrent()
FSEventStreamScheduleWithRunLoop(
self._stream_ref, self._run_loop, kCFRunLoopDefaultMode
)
if not FSEventStreamStart(self._stream_ref):
FSEventStreamInvalidate(self._stream_ref)
FSEventStreamRelease(self._stream_ref)
raise OSError("FSEvents. Could not start stream.")
CFRunLoopRun()
FSEventStreamStop(self._stream_ref)
FSEventStreamInvalidate(self._stream_ref)
FSEventStreamRelease(self._stream_ref)
del pool
# Make sure waiting thread is notified
self._queue.put(None)
def stop(self):
if self._run_loop is not None:
CFRunLoopStop(self._run_loop)
def _callback(
self, streamRef, clientCallBackInfo, numEvents, eventPaths, eventFlags, eventIDs
):
events = [
NativeEvent(path, flags, _id)
for path, flags, _id in zip(eventPaths, eventFlags, eventIDs)
]
logger.debug(f"FSEvents callback. Got {numEvents} events:")
for e in events:
logger.debug(e)
self._queue.put(events)
def read_events(self):
"""
Returns a list or one or more events, or None if there are no more
events to be read.
"""
if not self.is_alive():
return None
return self._queue.get()
class NativeEvent:
def __init__(self, path, flags, event_id):
self.path = path
self.flags = flags
self.event_id = event_id
self.is_created = bool(flags & kFSEventStreamEventFlagItemCreated)
self.is_removed = bool(flags & kFSEventStreamEventFlagItemRemoved)
self.is_renamed = bool(flags & kFSEventStreamEventFlagItemRenamed)
self.is_modified = bool(flags & kFSEventStreamEventFlagItemModified)
self.is_change_owner = bool(flags & kFSEventStreamEventFlagItemChangeOwner)
self.is_inode_meta_mod = bool(flags & kFSEventStreamEventFlagItemInodeMetaMod)
self.is_finder_info_mod = bool(flags & kFSEventStreamEventFlagItemFinderInfoMod)
self.is_xattr_mod = bool(flags & kFSEventStreamEventFlagItemXattrMod)
self.is_symlink = bool(flags & kFSEventStreamEventFlagItemIsSymlink)
self.is_directory = bool(flags & kFSEventStreamEventFlagItemIsDir)
@property
def _event_type(self):
if self.is_created:
return "Created"
if self.is_removed:
return "Removed"
if self.is_renamed:
return "Renamed"
if self.is_modified:
return "Modified"
if self.is_inode_meta_mod:
return "InodeMetaMod"
if self.is_xattr_mod:
return "XattrMod"
return "Unknown"
def __repr__(self):
return (
f"<{type(self).__name__}: path={self.path!r}, type={self._event_type},"
f" is_dir={self.is_directory}, flags={hex(self.flags)}, id={self.event_id}>"
)
class FSEventsEmitter(EventEmitter):
"""
FSEvents based event emitter. Handles conversion of native events.
"""
def __init__(self, event_queue, watch, timeout=DEFAULT_EMITTER_TIMEOUT):
super().__init__(event_queue, watch, timeout)
self._fsevents = FSEventsQueue(watch.path)
self._fsevents.start()
def on_thread_stop(self):
self._fsevents.stop()
def queue_events(self, timeout):
events = self._fsevents.read_events()
if events is None:
return
i = 0
while i < len(events):
event = events[i]
cls: Type[FileSystemEvent]
# 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 single 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
self.queue_event(cls(event.path, events[i + 1].path))
self.queue_event(DirModifiedEvent(os.path.dirname(event.path)))
self.queue_event(
DirModifiedEvent(os.path.dirname(events[i + 1].path))
)
i += 1
elif os.path.exists(event.path):
cls = DirCreatedEvent if event.is_directory else FileCreatedEvent
self.queue_event(cls(event.path))
self.queue_event(DirModifiedEvent(os.path.dirname(event.path)))
else:
cls = DirDeletedEvent if event.is_directory else FileDeletedEvent
self.queue_event(cls(event.path))
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:
cls = DirModifiedEvent if event.is_directory else FileModifiedEvent
self.queue_event(cls(event.path))
elif event.is_created:
cls = DirCreatedEvent if event.is_directory else FileCreatedEvent
self.queue_event(cls(event.path))
self.queue_event(DirModifiedEvent(os.path.dirname(event.path)))
elif event.is_removed:
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
class FSEventsObserver2(BaseObserver):
def __init__(self, timeout=DEFAULT_OBSERVER_TIMEOUT):
super().__init__(emitter_class=FSEventsEmitter, timeout=timeout)

View File

@ -0,0 +1,238 @@
# Copyright 2011 Yesudeep Mangalapilly <yesudeep@gmail.com>
# Copyright 2012 Google, Inc & contributors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
:module: libs.watchdog.observers.inotify
:synopsis: ``inotify(7)`` based emitter implementation.
:author: Sebastien Martini <seb@dbzteam.org>
:author: Luke McCarthy <luke@iogopro.co.uk>
:author: yesudeep@google.com (Yesudeep Mangalapilly)
:author: Tim Cuthbertson <tim+github@gfxmonk.net>
:platforms: Linux 2.6.13+.
.. ADMONITION:: About system requirements
Recommended minimum kernel version: 2.6.25.
Quote from the inotify(7) man page:
"Inotify was merged into the 2.6.13 Linux kernel. The required library
interfaces were added to glibc in version 2.4. (IN_DONT_FOLLOW,
IN_MASK_ADD, and IN_ONLYDIR were only added in version 2.5.)"
Therefore, you must ensure the system is running at least these versions
appropriate libraries and the kernel.
.. ADMONITION:: About recursiveness, event order, and event coalescing
Quote from the inotify(7) man page:
If successive output inotify events produced on the inotify file
descriptor are identical (same wd, mask, cookie, and name) then they
are coalesced into a single event if the older event has not yet been
read (but see BUGS).
The events returned by reading from an inotify file descriptor form
an ordered queue. Thus, for example, it is guaranteed that when
renaming from one directory to another, events will be produced in
the correct order on the inotify file descriptor.
...
Inotify monitoring of directories is not recursive: to monitor
subdirectories under a directory, additional watches must be created.
This emitter implementation therefore automatically adds watches for
sub-directories if running in recursive mode.
Some extremely useful articles and documentation:
.. _inotify FAQ: http://inotify.aiken.cz/?section=inotify&page=faq&lang=en
.. _intro to inotify: http://www.linuxjournal.com/article/8478
"""
from __future__ import annotations
import logging
import os
import threading
from typing import Type
from libs.watchdog.events import (
DirCreatedEvent,
DirDeletedEvent,
DirModifiedEvent,
DirMovedEvent,
FileClosedEvent,
FileCreatedEvent,
FileDeletedEvent,
FileModifiedEvent,
FileMovedEvent,
FileOpenedEvent,
FileSystemEvent,
generate_sub_created_events,
generate_sub_moved_events,
)
from libs.watchdog.observers.api import DEFAULT_EMITTER_TIMEOUT, DEFAULT_OBSERVER_TIMEOUT, BaseObserver, EventEmitter
from .inotify_buffer import InotifyBuffer
logger = logging.getLogger(__name__)
class InotifyEmitter(EventEmitter):
"""
inotify(7)-based event emitter.
:param event_queue:
The event queue to fill with events.
:param watch:
A watch object representing the directory to monitor.
:type watch:
:class:`libs.watchdog.observers.api.ObservedWatch`
:param timeout:
Read events blocking timeout (in seconds).
:type timeout:
``float``
"""
def __init__(self, event_queue, watch, timeout=DEFAULT_EMITTER_TIMEOUT):
super().__init__(event_queue, watch, timeout)
self._lock = threading.Lock()
self._inotify = None
def on_thread_start(self):
path = os.fsencode(self.watch.path)
self._inotify = InotifyBuffer(path, self.watch.is_recursive)
def on_thread_stop(self):
if self._inotify:
self._inotify.close()
self._inotify = None
def queue_events(self, timeout, full_events=False):
# If "full_events" is true, then the method will report unmatched move events as separate events
# This behavior is by default only called by a InotifyFullEmitter
if self._inotify is None:
logger.error("InotifyEmitter.queue_events() called when the thread is inactive")
return
with self._lock:
if self._inotify is None:
logger.error("InotifyEmitter.queue_events() called when the thread is inactive")
return
event = self._inotify.read_event()
if event is None:
return
cls: Type[FileSystemEvent]
if isinstance(event, tuple):
move_from, move_to = event
src_path = self._decode_path(move_from.src_path)
dest_path = self._decode_path(move_to.src_path)
cls = DirMovedEvent if move_from.is_directory else FileMovedEvent
self.queue_event(cls(src_path, dest_path))
self.queue_event(DirModifiedEvent(os.path.dirname(src_path)))
self.queue_event(DirModifiedEvent(os.path.dirname(dest_path)))
if move_from.is_directory and self.watch.is_recursive:
for sub_event in generate_sub_moved_events(src_path, dest_path):
self.queue_event(sub_event)
return
src_path = self._decode_path(event.src_path)
if event.is_moved_to:
if full_events:
cls = DirMovedEvent if event.is_directory else FileMovedEvent
self.queue_event(cls(None, src_path))
else:
cls = DirCreatedEvent if event.is_directory else FileCreatedEvent
self.queue_event(cls(src_path))
self.queue_event(DirModifiedEvent(os.path.dirname(src_path)))
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:
cls = DirModifiedEvent if event.is_directory else FileModifiedEvent
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):
cls = DirDeletedEvent if event.is_directory else FileDeletedEvent
self.queue_event(cls(src_path))
self.queue_event(DirModifiedEvent(os.path.dirname(src_path)))
elif event.is_moved_from and full_events:
cls = DirMovedEvent if event.is_directory else FileMovedEvent
self.queue_event(cls(src_path, None))
self.queue_event(DirModifiedEvent(os.path.dirname(src_path)))
elif event.is_create:
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_close_nowrite and not event.is_directory:
# cls = FileClosedEvent
# 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()
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)
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:`libs.watchdog.observers.api.ObservedWatch`
:param timeout:
Read events blocking timeout (in seconds).
:type timeout:
``float``
"""
def __init__(self, event_queue, watch, timeout=DEFAULT_EMITTER_TIMEOUT):
super().__init__(event_queue, watch, timeout)
def queue_events(self, timeout, events=True):
InotifyEmitter.queue_events(self, timeout, full_events=events)
class InotifyObserver(BaseObserver):
"""
Observer thread that schedules watching directories and dispatches
calls to event handlers.
"""
def __init__(self, timeout=DEFAULT_OBSERVER_TIMEOUT, generate_full_events=False):
cls = InotifyFullEmitter if generate_full_events else InotifyEmitter
super().__init__(emitter_class=cls, timeout=timeout)

View File

@ -0,0 +1,118 @@
# Copyright 2014 Thomas Amland <thomas.amland@gmail.com>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, List, Tuple, Union
from libs.watchdog.observers.inotify_c import Inotify, InotifyEvent
from libs.watchdog.utils import BaseThread
from libs.watchdog.utils.delayed_queue import DelayedQueue
logger = logging.getLogger(__name__)
class InotifyBuffer(BaseThread):
"""A wrapper for `Inotify` that holds events for `delay` seconds. During
this time, IN_MOVED_FROM and IN_MOVED_TO events are paired.
"""
delay = 0.5
def __init__(self, path, recursive=False):
super().__init__()
self._queue = DelayedQueue[InotifyEvent](self.delay)
self._inotify = Inotify(path, recursive)
self.start()
def read_event(self):
"""Returns a single event or a tuple of from/to events in case of a
paired move event. If this buffer has been closed, immediately return
None.
"""
return self._queue.get()
def on_thread_stop(self):
self._inotify.close()
self._queue.close()
def close(self):
self.stop()
self.join()
def _group_events(self, event_list):
"""Group any matching move events"""
grouped: List[Union[InotifyEvent, Tuple[InotifyEvent, InotifyEvent]]] = []
for inotify_event in event_list:
logger.debug("in-event %s", inotify_event)
def matching_from_event(event):
return (
not isinstance(event, tuple)
and event.is_moved_from
and event.cookie == inotify_event.cookie
)
if inotify_event.is_moved_to:
# Check if move_from is already in the buffer
for index, event in enumerate(grouped):
if matching_from_event(event):
if TYPE_CHECKING:
# this check is hidden from mypy inside matching_from_event()
assert not isinstance(event, tuple)
grouped[index] = (event, inotify_event)
break
else:
# Check if move_from is in delayqueue already
from_event = self._queue.remove(matching_from_event)
if from_event is not None:
grouped.append((from_event, inotify_event))
else:
logger.debug("could not find matching move_from event")
grouped.append(inotify_event)
else:
grouped.append(inotify_event)
return grouped
def run(self):
"""Read event from `inotify` and add them to `queue`. When reading a
IN_MOVE_TO event, remove the previous added matching IN_MOVE_FROM event
and add them back to the queue as a tuple.
"""
deleted_self = False
while self.should_keep_running() and not deleted_self:
inotify_events = self._inotify.read_events()
grouped_events = self._group_events(inotify_events)
for inotify_event in grouped_events:
if not isinstance(inotify_event, tuple) and inotify_event.is_ignored:
if inotify_event.src_path == self._inotify.path:
# Watch was removed explicitly (inotify_rm_watch(2)) or automatically (file
# was deleted, or filesystem was unmounted), stop watching for events
deleted_self = True
continue
# Only add delay for unmatched move_from events
delay = (
not isinstance(inotify_event, tuple) and inotify_event.is_moved_from
)
self._queue.put(inotify_event, delay)
if (
not isinstance(inotify_event, tuple)
and inotify_event.is_delete_self
and inotify_event.src_path == self._inotify.path
):
# Deleted the watched directory, stop watching for events
deleted_self = True

View File

@ -0,0 +1,602 @@
# Copyright 2011 Yesudeep Mangalapilly <yesudeep@gmail.com>
# Copyright 2012 Google, Inc & contributors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import ctypes
import ctypes.util
import errno
import os
import struct
import threading
from ctypes import c_char_p, c_int, c_uint32
from functools import reduce
from libs.watchdog.utils import UnsupportedLibc
libc = ctypes.CDLL(None)
if (
not hasattr(libc, "inotify_init")
or not hasattr(libc, "inotify_add_watch")
or not hasattr(libc, "inotify_rm_watch")
):
raise UnsupportedLibc(f"Unsupported libc version found: {libc._name}")
inotify_add_watch = ctypes.CFUNCTYPE(c_int, c_int, c_char_p, c_uint32, use_errno=True)(
("inotify_add_watch", libc)
)
inotify_rm_watch = ctypes.CFUNCTYPE(c_int, c_int, c_uint32, use_errno=True)(
("inotify_rm_watch", libc)
)
inotify_init = ctypes.CFUNCTYPE(c_int, use_errno=True)(("inotify_init", libc))
class InotifyConstants:
# User-space events
IN_ACCESS = 0x00000001 # File was accessed.
IN_MODIFY = 0x00000002 # File was modified.
IN_ATTRIB = 0x00000004 # Meta-data changed.
IN_CLOSE_WRITE = 0x00000008 # Writable file was closed.
IN_CLOSE_NOWRITE = 0x00000010 # Unwritable file closed.
IN_OPEN = 0x00000020 # File was opened.
IN_MOVED_FROM = 0x00000040 # File was moved from X.
IN_MOVED_TO = 0x00000080 # File was moved to Y.
IN_CREATE = 0x00000100 # Subfile was created.
IN_DELETE = 0x00000200 # Subfile was deleted.
IN_DELETE_SELF = 0x00000400 # Self was deleted.
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.
IN_UNMOUNT = 0x00002000 # Backing file system was unmounted.
IN_Q_OVERFLOW = 0x00004000 # Event queued overflowed.
IN_IGNORED = 0x00008000 # File was ignored.
# Special flags.
IN_ONLYDIR = 0x01000000 # Only watch the path if it's a directory.
IN_DONT_FOLLOW = 0x02000000 # Do not follow a symbolic link.
IN_EXCL_UNLINK = 0x04000000 # Exclude events on unlinked objects
IN_MASK_ADD = 0x20000000 # Add to the mask of an existing watch.
IN_ISDIR = 0x40000000 # Event occurred against directory.
IN_ONESHOT = 0x80000000 # Only send event once.
# All user-space events.
IN_ALL_EVENTS = reduce(
lambda x, y: x | y,
[
IN_ACCESS,
IN_MODIFY,
IN_ATTRIB,
IN_CLOSE_WRITE,
IN_CLOSE_NOWRITE,
IN_OPEN,
IN_MOVED_FROM,
IN_MOVED_TO,
IN_DELETE,
IN_CREATE,
IN_DELETE_SELF,
IN_MOVE_SELF,
],
)
# Flags for ``inotify_init1``
IN_CLOEXEC = 0x02000000
IN_NONBLOCK = 0x00004000
# Watchdog's API cares only about these events.
WATCHDOG_ALL_EVENTS = reduce(
lambda x, y: x | y,
[
InotifyConstants.IN_MODIFY,
InotifyConstants.IN_ATTRIB,
InotifyConstants.IN_MOVED_FROM,
InotifyConstants.IN_MOVED_TO,
InotifyConstants.IN_CREATE,
InotifyConstants.IN_DELETE,
InotifyConstants.IN_DELETE_SELF,
InotifyConstants.IN_DONT_FOLLOW,
InotifyConstants.IN_CLOSE_WRITE,
InotifyConstants.IN_OPEN,
],
)
class inotify_event_struct(ctypes.Structure):
"""
Structure representation of the inotify_event structure
(used in buffer size calculations)::
struct inotify_event {
__s32 wd; /* watch descriptor */
__u32 mask; /* watch mask */
__u32 cookie; /* cookie to synchronize two events */
__u32 len; /* length (including nulls) of name */
char name[0]; /* stub for possible name */
};
"""
_fields_ = [
("wd", c_int),
("mask", c_uint32),
("cookie", c_uint32),
("len", c_uint32),
("name", c_char_p),
]
EVENT_SIZE = ctypes.sizeof(inotify_event_struct)
DEFAULT_NUM_EVENTS = 2048
DEFAULT_EVENT_BUFFER_SIZE = DEFAULT_NUM_EVENTS * (EVENT_SIZE + 16)
class Inotify:
"""
Linux inotify(7) API wrapper class.
:param path:
The directory path for which we want an inotify object.
:type path:
:class:`bytes`
:param recursive:
``True`` if subdirectories should be monitored; ``False`` otherwise.
"""
def __init__(self, path, recursive=False, event_mask=WATCHDOG_ALL_EVENTS):
# The file descriptor associated with the inotify instance.
inotify_fd = inotify_init()
if inotify_fd == -1:
Inotify._raise_error()
self._inotify_fd = inotify_fd
self._lock = threading.Lock()
# Stores the watch descriptor for a given path.
self._wd_for_path = {}
self._path_for_wd = {}
self._path = path
self._event_mask = event_mask
self._is_recursive = recursive
if os.path.isdir(path):
self._add_dir_watch(path, recursive, event_mask)
else:
self._add_watch(path, event_mask)
self._moved_from_events = {}
@property
def event_mask(self):
"""The event mask for this inotify instance."""
return self._event_mask
@property
def path(self):
"""The path associated with the inotify instance."""
return self._path
@property
def is_recursive(self):
"""Whether we are watching directories recursively."""
return self._is_recursive
@property
def fd(self):
"""The file descriptor associated with the inotify instance."""
return self._inotify_fd
def clear_move_records(self):
"""Clear cached records of MOVED_FROM events"""
self._moved_from_events = {}
def source_for_move(self, destination_event):
"""
The source path corresponding to the given MOVED_TO event.
If the source path is outside the monitored directories, None
is returned instead.
"""
if destination_event.cookie in self._moved_from_events:
return self._moved_from_events[destination_event.cookie].src_path
else:
return None
def remember_move_from_event(self, event):
"""
Save this event as the source event for future MOVED_TO events to
reference.
"""
self._moved_from_events[event.cookie] = event
def add_watch(self, path):
"""
Adds a watch for the given path.
:param path:
Path to begin monitoring.
"""
with self._lock:
self._add_watch(path, self._event_mask)
def remove_watch(self, path):
"""
Removes a watch for the given path.
:param path:
Path string for which the watch will be removed.
"""
with self._lock:
wd = self._wd_for_path.pop(path)
del self._path_for_wd[wd]
if inotify_rm_watch(self._inotify_fd, wd) == -1:
Inotify._raise_error()
def close(self):
"""
Closes the inotify instance and removes all associated watches.
"""
with self._lock:
if self._path in self._wd_for_path:
wd = self._wd_for_path[self._path]
inotify_rm_watch(self._inotify_fd, wd)
try:
os.close(self._inotify_fd)
except OSError:
# descriptor may be invalid because file was deleted
pass
def read_events(self, event_buffer_size=DEFAULT_EVENT_BUFFER_SIZE):
"""
Reads events from inotify and yields them.
"""
# HACK: We need to traverse the directory path
# recursively and simulate events for newly
# created subdirectories/files. This will handle
# mkdir -p foobar/blah/bar; touch foobar/afile
def _recursive_simulate(src_path):
events = []
for root, dirnames, filenames in os.walk(src_path):
for dirname in dirnames:
try:
full_path = os.path.join(root, dirname)
wd_dir = self._add_watch(full_path, self._event_mask)
e = InotifyEvent(
wd_dir,
InotifyConstants.IN_CREATE | InotifyConstants.IN_ISDIR,
0,
dirname,
full_path,
)
events.append(e)
except OSError:
pass
for filename in filenames:
full_path = os.path.join(root, filename)
wd_parent_dir = self._wd_for_path[os.path.dirname(full_path)]
e = InotifyEvent(
wd_parent_dir,
InotifyConstants.IN_CREATE,
0,
filename,
full_path,
)
events.append(e)
return events
event_buffer = None
while True:
try:
event_buffer = os.read(self._inotify_fd, event_buffer_size)
except OSError as e:
if e.errno == errno.EINTR:
continue
elif e.errno == errno.EBADF:
return []
else:
raise
break
with self._lock:
event_list = []
for wd, mask, cookie, name in Inotify._parse_event_buffer(event_buffer):
if wd == -1:
continue
wd_path = self._path_for_wd[wd]
src_path = (
os.path.join(wd_path, name) if name else wd_path
) # avoid trailing slash
inotify_event = InotifyEvent(wd, mask, cookie, name, src_path)
if inotify_event.is_moved_from:
self.remember_move_from_event(inotify_event)
elif inotify_event.is_moved_to:
move_src_path = self.source_for_move(inotify_event)
if move_src_path in self._wd_for_path:
moved_wd = self._wd_for_path[move_src_path]
del self._wd_for_path[move_src_path]
self._wd_for_path[inotify_event.src_path] = moved_wd
self._path_for_wd[moved_wd] = inotify_event.src_path
if self.is_recursive:
for _path, _wd in self._wd_for_path.copy().items():
if _path.startswith(
move_src_path + os.path.sep.encode()
):
moved_wd = self._wd_for_path.pop(_path)
_move_to_path = _path.replace(
move_src_path, inotify_event.src_path
)
self._wd_for_path[_move_to_path] = moved_wd
self._path_for_wd[moved_wd] = _move_to_path
src_path = os.path.join(wd_path, name)
inotify_event = InotifyEvent(wd, mask, cookie, name, src_path)
if inotify_event.is_ignored:
# Clean up book-keeping for deleted watches.
path = self._path_for_wd.pop(wd)
if self._wd_for_path[path] == wd:
del self._wd_for_path[path]
event_list.append(inotify_event)
if (
self.is_recursive
and inotify_event.is_directory
and inotify_event.is_create
):
# TODO: When a directory from another part of the
# filesystem is moved into a watched directory, this
# will not generate events for the directory tree.
# We need to coalesce IN_MOVED_TO events and those
# IN_MOVED_TO events which don't pair up with
# IN_MOVED_FROM events should be marked IN_CREATE
# instead relative to this directory.
try:
self._add_watch(src_path, self._event_mask)
except OSError:
continue
event_list.extend(_recursive_simulate(src_path))
return event_list
# Non-synchronized methods.
def _add_dir_watch(self, path, recursive, mask):
"""
Adds a watch (optionally recursively) for the given directory path
to monitor events specified by the mask.
:param path:
Path to monitor
:param recursive:
``True`` to monitor recursively.
:param mask:
Event bit mask.
"""
if not os.path.isdir(path):
raise OSError(errno.ENOTDIR, os.strerror(errno.ENOTDIR), path)
self._add_watch(path, mask)
if recursive:
for root, dirnames, _ in os.walk(path):
for dirname in dirnames:
full_path = os.path.join(root, dirname)
if os.path.islink(full_path):
continue
self._add_watch(full_path, mask)
def _add_watch(self, path, mask):
"""
Adds a watch for the given path to monitor events specified by the
mask.
:param path:
Path to monitor
:param mask:
Event bit mask.
"""
wd = inotify_add_watch(self._inotify_fd, path, mask)
if wd == -1:
Inotify._raise_error()
self._wd_for_path[path] = wd
self._path_for_wd[wd] = path
return wd
@staticmethod
def _raise_error():
"""
Raises errors for inotify failures.
"""
err = ctypes.get_errno()
if err == errno.ENOSPC:
raise OSError(errno.ENOSPC, "inotify watch limit reached")
elif err == errno.EMFILE:
raise OSError(errno.EMFILE, "inotify instance limit reached")
elif err != errno.EACCES:
raise OSError(err, os.strerror(err))
@staticmethod
def _parse_event_buffer(event_buffer):
"""
Parses an event buffer of ``inotify_event`` structs returned by
inotify::
struct inotify_event {
__s32 wd; /* watch descriptor */
__u32 mask; /* watch mask */
__u32 cookie; /* cookie to synchronize two events */
__u32 len; /* length (including nulls) of name */
char name[0]; /* stub for possible name */
};
The ``cookie`` member of this struct is used to pair two related
events, for example, it pairs an IN_MOVED_FROM event with an
IN_MOVED_TO event.
"""
i = 0
while i + 16 <= len(event_buffer):
wd, mask, cookie, length = struct.unpack_from("iIII", event_buffer, i)
name = event_buffer[i + 16 : i + 16 + length].rstrip(b"\0")
i += 16 + length
yield wd, mask, cookie, name
class InotifyEvent:
"""
Inotify event struct wrapper.
:param wd:
Watch descriptor
:param mask:
Event mask
:param cookie:
Event cookie
:param name:
Base name of the event source path.
:param src_path:
Full event source path.
"""
def __init__(self, wd, mask, cookie, name, src_path):
self._wd = wd
self._mask = mask
self._cookie = cookie
self._name = name
self._src_path = src_path
@property
def src_path(self):
return self._src_path
@property
def wd(self):
return self._wd
@property
def mask(self):
return self._mask
@property
def cookie(self):
return self._cookie
@property
def name(self):
return self._name
@property
def is_modify(self):
return self._mask & InotifyConstants.IN_MODIFY > 0
@property
def is_close_write(self):
return self._mask & InotifyConstants.IN_CLOSE_WRITE > 0
@property
def is_close_nowrite(self):
return self._mask & InotifyConstants.IN_CLOSE_NOWRITE > 0
@property
def is_open(self):
return self._mask & InotifyConstants.IN_OPEN > 0
@property
def is_access(self):
return self._mask & InotifyConstants.IN_ACCESS > 0
@property
def is_delete(self):
return self._mask & InotifyConstants.IN_DELETE > 0
@property
def is_delete_self(self):
return self._mask & InotifyConstants.IN_DELETE_SELF > 0
@property
def is_create(self):
return self._mask & InotifyConstants.IN_CREATE > 0
@property
def is_moved_from(self):
return self._mask & InotifyConstants.IN_MOVED_FROM > 0
@property
def is_moved_to(self):
return self._mask & InotifyConstants.IN_MOVED_TO > 0
@property
def is_move(self):
return self._mask & InotifyConstants.IN_MOVE > 0
@property
def is_move_self(self):
return self._mask & InotifyConstants.IN_MOVE_SELF > 0
@property
def is_attrib(self):
return self._mask & InotifyConstants.IN_ATTRIB > 0
@property
def is_ignored(self):
return self._mask & InotifyConstants.IN_IGNORED > 0
@property
def is_directory(self):
# It looks like the kernel does not provide this information for
# IN_DELETE_SELF and IN_MOVE_SELF. In this case, assume it's a dir.
# See also: https://github.com/seb-m/pyinotify/blob/2c7e8f8/python2/pyinotify.py#L897
return (
self.is_delete_self
or self.is_move_self
or self._mask & InotifyConstants.IN_ISDIR > 0
)
@property
def key(self):
return self._src_path, self._wd, self._mask, self._cookie, self._name
def __eq__(self, inotify_event):
return self.key == inotify_event.key
def __ne__(self, inotify_event):
return self.key != inotify_event.key
def __hash__(self):
return hash(self.key)
@staticmethod
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",
]:
c_val = getattr(InotifyConstants, c)
if mask & c_val:
masks.append(c)
return "|".join(masks)
def __repr__(self):
return (
f"<{type(self).__name__}: src_path={self.src_path!r}, wd={self.wd},"
f" mask={self._get_mask_string(self.mask)}, cookie={self.cookie},"
f" name={os.fsdecode(self.name)!r}>"
)

View File

@ -0,0 +1,699 @@
# Copyright 2011 Yesudeep Mangalapilly <yesudeep@gmail.com>
# Copyright 2012 Google, Inc & contributors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
# The `select` module varies between platforms.
# mypy may complain about missing module attributes
# depending on which platform it's running on.
# The comment below disables mypy's attribute check.
#
# mypy: disable-error-code=attr-defined
#
"""
:module: libs.watchdog.observers.kqueue
:synopsis: ``kqueue(2)`` based emitter implementation.
:author: yesudeep@google.com (Yesudeep Mangalapilly)
:author: contact@tiger-222.fr (Mickaël Schoentgen)
:platforms: macOS and BSD with kqueue(2).
.. WARNING:: kqueue is a very heavyweight way to monitor file systems.
Each kqueue-detected directory modification triggers
a full directory scan. Traversing the entire directory tree
and opening file descriptors for all files will create
performance problems. We need to find a way to re-scan
only those directories which report changes and do a diff
between two sub-DirectorySnapshots perhaps.
.. ADMONITION:: About OS X performance guidelines
Quote from the `macOS File System Performance Guidelines`_:
"When you only want to track changes on a file or directory, be sure to
open it using the ``O_EVTONLY`` flag. This flag prevents the file or
directory from being marked as open or in use. This is important
if you are tracking files on a removable volume and the user tries to
unmount the volume. With this flag in place, the system knows it can
dismiss the volume. If you had opened the files or directories without
this flag, the volume would be marked as busy and would not be
unmounted."
``O_EVTONLY`` is defined as ``0x8000`` in the OS X header files.
More information here: http://www.mlsite.net/blog/?p=2312
Classes
-------
.. autoclass:: KqueueEmitter
:members:
:show-inheritance:
Collections and Utility Classes
-------------------------------
.. autoclass:: KeventDescriptor
:members:
:show-inheritance:
.. autoclass:: KeventDescriptorSet
:members:
:show-inheritance:
.. _macOS File System Performance Guidelines:
http://developer.apple.com/library/ios/#documentation/Performance/Conceptual/FileSystem/Articles/TrackingChanges.html#//apple_ref/doc/uid/20001993-CJBJFIDD
"""
import errno
import os
import os.path
import select
import threading
from stat import S_ISDIR
from libs.watchdog.events import (
EVENT_TYPE_CREATED,
EVENT_TYPE_DELETED,
EVENT_TYPE_MOVED,
DirCreatedEvent,
DirDeletedEvent,
DirModifiedEvent,
DirMovedEvent,
FileCreatedEvent,
FileDeletedEvent,
FileModifiedEvent,
FileMovedEvent,
generate_sub_moved_events,
)
from libs.watchdog.observers.api import DEFAULT_EMITTER_TIMEOUT, DEFAULT_OBSERVER_TIMEOUT, BaseObserver, EventEmitter
from libs.watchdog.utils import platform
from libs.watchdog.utils.dirsnapshot import DirectorySnapshot
# Maximum number of events to process.
MAX_EVENTS = 4096
# O_EVTONLY value from the header files for OS X only.
O_EVTONLY = 0x8000
# Pre-calculated values for the kevent filter, flags, and fflags attributes.
if platform.is_darwin():
WATCHDOG_OS_OPEN_FLAGS = O_EVTONLY
else:
WATCHDOG_OS_OPEN_FLAGS = os.O_RDONLY | os.O_NONBLOCK
WATCHDOG_KQ_FILTER = select.KQ_FILTER_VNODE
WATCHDOG_KQ_EV_FLAGS = select.KQ_EV_ADD | select.KQ_EV_ENABLE | select.KQ_EV_CLEAR
WATCHDOG_KQ_FFLAGS = (
select.KQ_NOTE_DELETE
| select.KQ_NOTE_WRITE
| select.KQ_NOTE_EXTEND
| select.KQ_NOTE_ATTRIB
| select.KQ_NOTE_LINK
| select.KQ_NOTE_RENAME
| select.KQ_NOTE_REVOKE
)
def absolute_path(path):
return os.path.abspath(os.path.normpath(path))
# Flag tests.
def is_deleted(kev):
"""Determines whether the given kevent represents deletion."""
return kev.fflags & select.KQ_NOTE_DELETE
def is_modified(kev):
"""Determines whether the given kevent represents modification."""
fflags = kev.fflags
return (fflags & select.KQ_NOTE_EXTEND) or (fflags & select.KQ_NOTE_WRITE)
def is_attrib_modified(kev):
"""Determines whether the given kevent represents attribute modification."""
return kev.fflags & select.KQ_NOTE_ATTRIB
def is_renamed(kev):
"""Determines whether the given kevent represents movement."""
return kev.fflags & select.KQ_NOTE_RENAME
class KeventDescriptorSet:
"""
Thread-safe kevent descriptor collection.
"""
def __init__(self):
# Set of KeventDescriptor
self._descriptors = set()
# Descriptor for a given path.
self._descriptor_for_path = dict()
# Descriptor for a given fd.
self._descriptor_for_fd = dict()
# List of kevent objects.
self._kevents = list()
self._lock = threading.Lock()
@property
def kevents(self):
"""
List of kevents monitored.
"""
with self._lock:
return self._kevents
@property
def paths(self):
"""
List of paths for which kevents have been created.
"""
with self._lock:
return list(self._descriptor_for_path.keys())
def get_for_fd(self, fd):
"""
Given a file descriptor, returns the kevent descriptor object
for it.
:param fd:
OS file descriptor.
:type fd:
``int``
:returns:
A :class:`KeventDescriptor` object.
"""
with self._lock:
return self._descriptor_for_fd[fd]
def get(self, path):
"""
Obtains a :class:`KeventDescriptor` object for the specified path.
:param path:
Path for which the descriptor will be obtained.
"""
with self._lock:
path = absolute_path(path)
return self._get(path)
def __contains__(self, path):
"""
Determines whether a :class:`KeventDescriptor has been registered
for the specified path.
:param path:
Path for which the descriptor will be obtained.
"""
with self._lock:
path = absolute_path(path)
return self._has_path(path)
def add(self, path, is_directory):
"""
Adds a :class:`KeventDescriptor` to the collection for the given
path.
:param path:
The path for which a :class:`KeventDescriptor` object will be
added.
:param is_directory:
``True`` if the path refers to a directory; ``False`` otherwise.
:type is_directory:
``bool``
"""
with self._lock:
path = absolute_path(path)
if not self._has_path(path):
self._add_descriptor(KeventDescriptor(path, is_directory))
def remove(self, path):
"""
Removes the :class:`KeventDescriptor` object for the given path
if it already exists.
:param path:
Path for which the :class:`KeventDescriptor` object will be
removed.
"""
with self._lock:
path = absolute_path(path)
if self._has_path(path):
self._remove_descriptor(self._get(path))
def clear(self):
"""
Clears the collection and closes all open descriptors.
"""
with self._lock:
for descriptor in self._descriptors:
descriptor.close()
self._descriptors.clear()
self._descriptor_for_fd.clear()
self._descriptor_for_path.clear()
self._kevents = []
# Thread-unsafe methods. Locking is provided at a higher level.
def _get(self, path):
"""Returns a kevent descriptor for a given path."""
return self._descriptor_for_path[path]
def _has_path(self, path):
"""Determines whether a :class:`KeventDescriptor` for the specified
path exists already in the collection."""
return path in self._descriptor_for_path
def _add_descriptor(self, descriptor):
"""
Adds a descriptor to the collection.
:param descriptor:
An instance of :class:`KeventDescriptor` to be added.
"""
self._descriptors.add(descriptor)
self._kevents.append(descriptor.kevent)
self._descriptor_for_path[descriptor.path] = descriptor
self._descriptor_for_fd[descriptor.fd] = descriptor
def _remove_descriptor(self, descriptor):
"""
Removes a descriptor from the collection.
:param descriptor:
An instance of :class:`KeventDescriptor` to be removed.
"""
self._descriptors.remove(descriptor)
del self._descriptor_for_fd[descriptor.fd]
del self._descriptor_for_path[descriptor.path]
self._kevents.remove(descriptor.kevent)
descriptor.close()
class KeventDescriptor:
"""
A kevent descriptor convenience data structure to keep together:
* kevent
* directory status
* path
* file descriptor
:param path:
Path string for which a kevent descriptor will be created.
:param is_directory:
``True`` if the path refers to a directory; ``False`` otherwise.
:type is_directory:
``bool``
"""
def __init__(self, path, is_directory):
self._path = absolute_path(path)
self._is_directory = is_directory
self._fd = os.open(path, WATCHDOG_OS_OPEN_FLAGS)
self._kev = select.kevent(
self._fd,
filter=WATCHDOG_KQ_FILTER,
flags=WATCHDOG_KQ_EV_FLAGS,
fflags=WATCHDOG_KQ_FFLAGS,
)
@property
def fd(self):
"""OS file descriptor for the kevent descriptor."""
return self._fd
@property
def path(self):
"""The path associated with the kevent descriptor."""
return self._path
@property
def kevent(self):
"""The kevent object associated with the kevent descriptor."""
return self._kev
@property
def is_directory(self):
"""Determines whether the kevent descriptor refers to a directory.
:returns:
``True`` or ``False``
"""
return self._is_directory
def close(self):
"""
Closes the file descriptor associated with a kevent descriptor.
"""
try:
os.close(self.fd)
except OSError:
pass
@property
def key(self):
return (self.path, self.is_directory)
def __eq__(self, descriptor):
return self.key == descriptor.key
def __ne__(self, descriptor):
return self.key != descriptor.key
def __hash__(self):
return hash(self.key)
def __repr__(self):
return f"<{type(self).__name__}: path={self.path!r}, is_directory={self.is_directory}>"
class KqueueEmitter(EventEmitter):
"""
kqueue(2)-based event emitter.
.. ADMONITION:: About ``kqueue(2)`` behavior and this implementation
``kqueue(2)`` monitors file system events only for
open descriptors, which means, this emitter does a lot of
book-keeping behind the scenes to keep track of open
descriptors for every entry in the monitored directory tree.
This also means the number of maximum open file descriptors
on your system must be increased **manually**.
Usually, issuing a call to ``ulimit`` should suffice::
ulimit -n 1024
Ensure that you pick a number that is larger than the
number of files you expect to be monitored.
``kqueue(2)`` does not provide enough information about the
following things:
* The destination path of a file or directory that is renamed.
* Creation of a file or directory within a directory; in this
case, ``kqueue(2)`` only indicates a modified event on the
parent directory.
Therefore, this emitter takes a snapshot of the directory
tree when ``kqueue(2)`` detects a change on the file system
to be able to determine the above information.
:param event_queue:
The event queue to fill with events.
:param watch:
A watch object representing the directory to monitor.
:type watch:
:class:`libs.watchdog.observers.api.ObservedWatch`
:param timeout:
Read events blocking timeout (in seconds).
:type timeout:
``float``
:param stat: stat function. See ``os.stat`` for details.
"""
def __init__(
self, event_queue, watch, timeout=DEFAULT_EMITTER_TIMEOUT, stat=os.stat
):
super().__init__(event_queue, watch, timeout)
self._kq = select.kqueue()
self._lock = threading.RLock()
# A collection of KeventDescriptor.
self._descriptors = KeventDescriptorSet()
def custom_stat(path, self=self):
stat_info = stat(path)
self._register_kevent(path, S_ISDIR(stat_info.st_mode))
return stat_info
self._snapshot = DirectorySnapshot(
watch.path, recursive=watch.is_recursive, stat=custom_stat
)
def _register_kevent(self, path, is_directory):
"""
Registers a kevent descriptor for the given path.
:param path:
Path for which a kevent descriptor will be created.
:param is_directory:
``True`` if the path refers to a directory; ``False`` otherwise.
:type is_directory:
``bool``
"""
try:
self._descriptors.add(path, is_directory)
except OSError as e:
if e.errno == errno.ENOENT:
# Probably dealing with a temporary file that was created
# and then quickly deleted before we could open
# a descriptor for it. Therefore, simply queue a sequence
# of created and deleted events for the path.
# path = absolute_path(path)
# if is_directory:
# self.queue_event(DirCreatedEvent(path))
# self.queue_event(DirDeletedEvent(path))
# else:
# self.queue_event(FileCreatedEvent(path))
# self.queue_event(FileDeletedEvent(path))
# TODO: We could simply ignore these files.
# Locked files cause the python process to die with
# a bus error when we handle temporary files.
# eg. .git/index.lock when running tig operations.
# I don't fully understand this at the moment.
pass
elif e.errno == errno.EOPNOTSUPP:
# Probably dealing with the socket or special file
# mounted through a file system that does not support
# access to it (e.g. NFS). On BSD systems look at
# EOPNOTSUPP in man 2 open.
pass
else:
# All other errors are propagated.
raise
def _unregister_kevent(self, path):
"""
Convenience function to close the kevent descriptor for a
specified kqueue-monitored path.
:param path:
Path for which the kevent descriptor will be closed.
"""
self._descriptors.remove(path)
def queue_event(self, event):
"""
Handles queueing a single event object.
:param event:
An instance of :class:`libs.watchdog.events.FileSystemEvent`
or a subclass.
"""
# Handles all the book keeping for queued events.
# We do not need to fire moved/deleted events for all subitems in
# a directory tree here, because this function is called by kqueue
# for all those events anyway.
EventEmitter.queue_event(self, event)
if event.event_type == EVENT_TYPE_CREATED:
self._register_kevent(event.src_path, event.is_directory)
elif event.event_type == EVENT_TYPE_MOVED:
self._unregister_kevent(event.src_path)
self._register_kevent(event.dest_path, event.is_directory)
elif event.event_type == EVENT_TYPE_DELETED:
self._unregister_kevent(event.src_path)
def _gen_kqueue_events(self, kev, ref_snapshot, new_snapshot):
"""
Generate events from the kevent list returned from the call to
:meth:`select.kqueue.control`.
.. NOTE:: kqueue only tells us about deletions, file modifications,
attribute modifications. The other events, namely,
file creation, directory modification, file rename,
directory rename, directory creation, etc. are
determined by comparing directory snapshots.
"""
descriptor = self._descriptors.get_for_fd(kev.ident)
src_path = descriptor.path
if is_renamed(kev):
# Kqueue does not specify the destination names for renames
# to, so we have to process these using the a snapshot
# of the directory.
for event in self._gen_renamed_events(
src_path, descriptor.is_directory, ref_snapshot, new_snapshot
):
yield event
elif is_attrib_modified(kev):
if descriptor.is_directory:
yield DirModifiedEvent(src_path)
else:
yield FileModifiedEvent(src_path)
elif is_modified(kev):
if descriptor.is_directory:
if self.watch.is_recursive or self.watch.path == src_path:
# When a directory is modified, it may be due to
# sub-file/directory renames or new file/directory
# creation. We determine all this by comparing
# snapshots later.
yield DirModifiedEvent(src_path)
else:
yield FileModifiedEvent(src_path)
elif is_deleted(kev):
if descriptor.is_directory:
yield DirDeletedEvent(src_path)
else:
yield FileDeletedEvent(src_path)
def _parent_dir_modified(self, src_path):
"""
Helper to generate a DirModifiedEvent on the parent of src_path.
"""
return DirModifiedEvent(os.path.dirname(src_path))
def _gen_renamed_events(self, src_path, is_directory, ref_snapshot, new_snapshot):
"""
Compares information from two directory snapshots (one taken before
the rename operation and another taken right after) to determine the
destination path of the file system object renamed, and yields
the appropriate events to be queued.
"""
try:
f_inode = ref_snapshot.inode(src_path)
except KeyError:
# Probably caught a temporary file/directory that was renamed
# and deleted. Fires a sequence of created and deleted events
# for the path.
if is_directory:
yield DirCreatedEvent(src_path)
yield DirDeletedEvent(src_path)
else:
yield FileCreatedEvent(src_path)
yield FileDeletedEvent(src_path)
# We don't process any further and bail out assuming
# the event represents deletion/creation instead of movement.
return
dest_path = new_snapshot.path(f_inode)
if dest_path is not None:
dest_path = absolute_path(dest_path)
if is_directory:
event = DirMovedEvent(src_path, dest_path)
yield event
else:
yield FileMovedEvent(src_path, dest_path)
yield self._parent_dir_modified(src_path)
yield self._parent_dir_modified(dest_path)
if is_directory:
# TODO: Do we need to fire moved events for the items
# inside the directory tree? Does kqueue does this
# all by itself? Check this and then enable this code
# only if it doesn't already.
# A: It doesn't. So I've enabled this block.
if self.watch.is_recursive:
for sub_event in generate_sub_moved_events(src_path, dest_path):
yield sub_event
else:
# If the new snapshot does not have an inode for the
# old path, we haven't found the new name. Therefore,
# we mark it as deleted and remove unregister the path.
if is_directory:
yield DirDeletedEvent(src_path)
else:
yield FileDeletedEvent(src_path)
yield self._parent_dir_modified(src_path)
def _read_events(self, timeout=None):
"""
Reads events from a call to the blocking
:meth:`select.kqueue.control()` method.
:param timeout:
Blocking timeout for reading events.
:type timeout:
``float`` (seconds)
"""
return self._kq.control(self._descriptors.kevents, MAX_EVENTS, timeout)
def queue_events(self, timeout):
"""
Queues events by reading them from a call to the blocking
:meth:`select.kqueue.control()` method.
:param timeout:
Blocking timeout for reading events.
:type timeout:
``float`` (seconds)
"""
with self._lock:
try:
event_list = self._read_events(timeout)
# TODO: investigate why order appears to be reversed
event_list.reverse()
# Take a fresh snapshot of the directory and update the
# saved snapshot.
new_snapshot = DirectorySnapshot(
self.watch.path, self.watch.is_recursive
)
ref_snapshot = self._snapshot
self._snapshot = new_snapshot
diff_events = new_snapshot - ref_snapshot
# Process events
for directory_created in diff_events.dirs_created:
self.queue_event(DirCreatedEvent(directory_created))
for file_created in diff_events.files_created:
self.queue_event(FileCreatedEvent(file_created))
for file_modified in diff_events.files_modified:
self.queue_event(FileModifiedEvent(file_modified))
for kev in event_list:
for event in self._gen_kqueue_events(
kev, ref_snapshot, new_snapshot
):
self.queue_event(event)
except OSError as e:
if e.errno != errno.EBADF:
raise
def on_thread_stop(self):
# Clean up.
with self._lock:
self._descriptors.clear()
self._kq.close()
class KqueueObserver(BaseObserver):
"""
Observer thread that schedules watching directories and dispatches
calls to event handlers.
"""
def __init__(self, timeout=DEFAULT_OBSERVER_TIMEOUT):
super().__init__(emitter_class=KqueueEmitter, timeout=timeout)

View File

@ -0,0 +1,145 @@
# Copyright 2011 Yesudeep Mangalapilly <yesudeep@gmail.com>
# Copyright 2012 Google, Inc & contributors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
:module: libs.watchdog.observers.polling
:synopsis: Polling emitter implementation.
:author: yesudeep@google.com (Yesudeep Mangalapilly)
:author: contact@tiger-222.fr (Mickaël Schoentgen)
Classes
-------
.. autoclass:: PollingObserver
:members:
:show-inheritance:
.. autoclass:: PollingObserverVFS
:members:
:show-inheritance:
:special-members:
"""
from __future__ import annotations
import os
import threading
from functools import partial
from libs.watchdog.events import (
DirCreatedEvent,
DirDeletedEvent,
DirModifiedEvent,
DirMovedEvent,
FileCreatedEvent,
FileDeletedEvent,
FileModifiedEvent,
FileMovedEvent,
)
from libs.watchdog.observers.api import DEFAULT_EMITTER_TIMEOUT, DEFAULT_OBSERVER_TIMEOUT, BaseObserver, EventEmitter
from libs.watchdog.utils.dirsnapshot import DirectorySnapshot, DirectorySnapshotDiff
class PollingEmitter(EventEmitter):
"""
Platform-independent emitter that polls a directory to detect file
system changes.
"""
def __init__(
self,
event_queue,
watch,
timeout=DEFAULT_EMITTER_TIMEOUT,
stat=os.stat,
listdir=os.scandir,
):
super().__init__(event_queue, watch, timeout)
self._snapshot = None
self._lock = threading.Lock()
self._take_snapshot = lambda: DirectorySnapshot(
self.watch.path, self.watch.is_recursive, stat=stat, listdir=listdir
)
def on_thread_start(self):
self._snapshot = self._take_snapshot()
def queue_events(self, timeout):
# We don't want to hit the disk continuously.
# timeout behaves like an interval for polling emitters.
if self.stopped_event.wait(timeout):
return
with self._lock:
if not self.should_keep_running():
return
# Get event diff between fresh snapshot and previous snapshot.
# Update snapshot.
try:
new_snapshot = self._take_snapshot()
except OSError:
self.queue_event(DirDeletedEvent(self.watch.path))
self.stop()
return
events = DirectorySnapshotDiff(self._snapshot, new_snapshot)
self._snapshot = new_snapshot
# Files.
for src_path in events.files_deleted:
self.queue_event(FileDeletedEvent(src_path))
for src_path in events.files_modified:
self.queue_event(FileModifiedEvent(src_path))
for src_path in events.files_created:
self.queue_event(FileCreatedEvent(src_path))
for src_path, dest_path in events.files_moved:
self.queue_event(FileMovedEvent(src_path, dest_path))
# Directories.
for src_path in events.dirs_deleted:
self.queue_event(DirDeletedEvent(src_path))
for src_path in events.dirs_modified:
self.queue_event(DirModifiedEvent(src_path))
for src_path in events.dirs_created:
self.queue_event(DirCreatedEvent(src_path))
for src_path, dest_path in events.dirs_moved:
self.queue_event(DirMovedEvent(src_path, dest_path))
class PollingObserver(BaseObserver):
"""
Platform-independent observer that polls a directory to detect file
system changes.
"""
def __init__(self, timeout=DEFAULT_OBSERVER_TIMEOUT):
super().__init__(emitter_class=PollingEmitter, timeout=timeout)
class PollingObserverVFS(BaseObserver):
"""
File system independent observer that polls a directory to detect changes.
"""
def __init__(self, stat, listdir, polling_interval=1):
"""
:param stat: stat function. See ``os.stat`` for details.
:param listdir: listdir function. See ``os.scandir`` for details.
:type polling_interval: float
:param polling_interval: interval in seconds between polling the file system.
"""
emitter_cls = partial(PollingEmitter, stat=stat, listdir=listdir)
super().__init__(emitter_class=emitter_cls, timeout=polling_interval)

View File

@ -0,0 +1,142 @@
# Copyright 2011 Yesudeep Mangalapilly <yesudeep@gmail.com>
# Copyright 2012 Google, Inc & contributors.
# Copyright 2014 Thomas Amland
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import os.path
import platform
import sys
import threading
import time
from libs.watchdog.events import (
DirCreatedEvent,
DirDeletedEvent,
DirModifiedEvent,
DirMovedEvent,
FileCreatedEvent,
FileDeletedEvent,
FileModifiedEvent,
FileMovedEvent,
generate_sub_created_events,
generate_sub_moved_events,
)
from libs.watchdog.observers.api import DEFAULT_EMITTER_TIMEOUT, DEFAULT_OBSERVER_TIMEOUT, BaseObserver, EventEmitter
assert sys.platform.startswith("win"), f"{__name__} requires Windows"
from libs.watchdog.observers.winapi import close_directory_handle, get_directory_handle, read_events # noqa: E402
# HACK:
WATCHDOG_TRAVERSE_MOVED_DIR_DELAY = 1 # seconds
class WindowsApiEmitter(EventEmitter):
"""
Windows API-based emitter that uses ReadDirectoryChangesW
to detect file system changes for a watch.
"""
def __init__(self, event_queue, watch, timeout=DEFAULT_EMITTER_TIMEOUT):
super().__init__(event_queue, watch, timeout)
self._lock = threading.Lock()
self._handle = None
def on_thread_start(self):
self._handle = get_directory_handle(self.watch.path)
if platform.python_implementation() == "PyPy":
def start(self):
"""PyPy needs some time before receiving events, see #792."""
super().start()
time.sleep(0.01)
def on_thread_stop(self):
if self._handle:
close_directory_handle(self._handle)
def _read_events(self):
return read_events(self._handle, self.watch.path, self.watch.is_recursive)
def queue_events(self, timeout):
winapi_events = self._read_events()
with self._lock:
last_renamed_src_path = ""
for winapi_event in winapi_events:
src_path = os.path.join(self.watch.path, winapi_event.src_path)
if winapi_event.is_renamed_old:
last_renamed_src_path = src_path
elif winapi_event.is_renamed_new:
dest_path = src_path
src_path = last_renamed_src_path
if os.path.isdir(dest_path):
event = DirMovedEvent(src_path, dest_path)
if self.watch.is_recursive:
# HACK: We introduce a forced delay before
# traversing the moved directory. This will read
# only file movement that finishes within this
# delay time.
time.sleep(WATCHDOG_TRAVERSE_MOVED_DIR_DELAY)
# The following block of code may not
# obtain moved events for the entire tree if
# the I/O is not completed within the above
# delay time. So, it's not guaranteed to work.
# TODO: Come up with a better solution, possibly
# a way to wait for I/O to complete before
# queuing events.
for sub_moved_event in generate_sub_moved_events(
src_path, dest_path
):
self.queue_event(sub_moved_event)
self.queue_event(event)
else:
self.queue_event(FileMovedEvent(src_path, dest_path))
elif winapi_event.is_modified:
cls = (
DirModifiedEvent
if os.path.isdir(src_path)
else FileModifiedEvent
)
self.queue_event(cls(src_path))
elif winapi_event.is_added:
isdir = os.path.isdir(src_path)
cls = DirCreatedEvent if isdir else FileCreatedEvent
self.queue_event(cls(src_path))
if isdir and self.watch.is_recursive:
# If a directory is moved from outside the watched folder to inside it
# we only get a created directory event out of it, not any events for its children
# so use the same hack as for file moves to get the child events
time.sleep(WATCHDOG_TRAVERSE_MOVED_DIR_DELAY)
sub_events = generate_sub_created_events(src_path)
for sub_created_event in sub_events:
self.queue_event(sub_created_event)
elif winapi_event.is_removed:
self.queue_event(FileDeletedEvent(src_path))
elif winapi_event.is_removed_self:
self.queue_event(DirDeletedEvent(self.watch.path))
self.stop()
class WindowsApiObserver(BaseObserver):
"""
Observer thread that schedules watching directories and dispatches
calls to event handlers.
"""
def __init__(self, timeout=DEFAULT_OBSERVER_TIMEOUT):
super().__init__(emitter_class=WindowsApiEmitter, timeout=timeout)

View File

@ -0,0 +1,416 @@
# winapi.py: Windows API-Python interface (removes dependency on pywin32)
#
# Copyright (C) 2007 Thomas Heller <theller@ctypes.org>
# Copyright (C) 2010 Will McGugan <will@willmcgugan.com>
# Copyright (C) 2010 Ryan Kelly <ryan@rfk.id.au>
# Copyright (C) 2010 Yesudeep Mangalapilly <yesudeep@gmail.com>
# Copyright (C) 2014 Thomas Amland
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and / or other materials provided with the distribution.
# * Neither the name of the organization nor the names of its contributors may
# be used to endorse or promote products derived from this software without
# specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# Portions of this code were taken from pyfilesystem, which uses the above
# new BSD license.
from __future__ import annotations
import sys
from functools import reduce
assert sys.platform.startswith("win"), f"{__name__} requires Windows"
import ctypes.wintypes # noqa: E402
LPVOID = ctypes.wintypes.LPVOID
# Invalid handle value.
INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
# File notification constants.
FILE_NOTIFY_CHANGE_FILE_NAME = 0x01
FILE_NOTIFY_CHANGE_DIR_NAME = 0x02
FILE_NOTIFY_CHANGE_ATTRIBUTES = 0x04
FILE_NOTIFY_CHANGE_SIZE = 0x08
FILE_NOTIFY_CHANGE_LAST_WRITE = 0x010
FILE_NOTIFY_CHANGE_LAST_ACCESS = 0x020
FILE_NOTIFY_CHANGE_CREATION = 0x040
FILE_NOTIFY_CHANGE_SECURITY = 0x0100
FILE_FLAG_BACKUP_SEMANTICS = 0x02000000
FILE_FLAG_OVERLAPPED = 0x40000000
FILE_LIST_DIRECTORY = 1
FILE_SHARE_READ = 0x01
FILE_SHARE_WRITE = 0x02
FILE_SHARE_DELETE = 0x04
OPEN_EXISTING = 3
VOLUME_NAME_NT = 0x02
# File action constants.
FILE_ACTION_CREATED = 1
FILE_ACTION_DELETED = 2
FILE_ACTION_MODIFIED = 3
FILE_ACTION_RENAMED_OLD_NAME = 4
FILE_ACTION_RENAMED_NEW_NAME = 5
FILE_ACTION_DELETED_SELF = 0xFFFE
FILE_ACTION_OVERFLOW = 0xFFFF
# Aliases
FILE_ACTION_ADDED = FILE_ACTION_CREATED
FILE_ACTION_REMOVED = FILE_ACTION_DELETED
FILE_ACTION_REMOVED_SELF = FILE_ACTION_DELETED_SELF
THREAD_TERMINATE = 0x0001
# IO waiting constants.
WAIT_ABANDONED = 0x00000080
WAIT_IO_COMPLETION = 0x000000C0
WAIT_OBJECT_0 = 0x00000000
WAIT_TIMEOUT = 0x00000102
# Error codes
ERROR_OPERATION_ABORTED = 995
class OVERLAPPED(ctypes.Structure):
_fields_ = [
("Internal", LPVOID),
("InternalHigh", LPVOID),
("Offset", ctypes.wintypes.DWORD),
("OffsetHigh", ctypes.wintypes.DWORD),
("Pointer", LPVOID),
("hEvent", ctypes.wintypes.HANDLE),
]
def _errcheck_bool(value, func, args):
if not value:
raise ctypes.WinError()
return args
def _errcheck_handle(value, func, args):
if not value:
raise ctypes.WinError()
if value == INVALID_HANDLE_VALUE:
raise ctypes.WinError()
return args
def _errcheck_dword(value, func, args):
if value == 0xFFFFFFFF:
raise ctypes.WinError()
return args
kernel32 = ctypes.WinDLL("kernel32")
ReadDirectoryChangesW = kernel32.ReadDirectoryChangesW
ReadDirectoryChangesW.restype = ctypes.wintypes.BOOL
ReadDirectoryChangesW.errcheck = _errcheck_bool
ReadDirectoryChangesW.argtypes = (
ctypes.wintypes.HANDLE, # hDirectory
LPVOID, # lpBuffer
ctypes.wintypes.DWORD, # nBufferLength
ctypes.wintypes.BOOL, # bWatchSubtree
ctypes.wintypes.DWORD, # dwNotifyFilter
ctypes.POINTER(ctypes.wintypes.DWORD), # lpBytesReturned
ctypes.POINTER(OVERLAPPED), # lpOverlapped
LPVOID, # FileIOCompletionRoutine # lpCompletionRoutine
)
CreateFileW = kernel32.CreateFileW
CreateFileW.restype = ctypes.wintypes.HANDLE
CreateFileW.errcheck = _errcheck_handle
CreateFileW.argtypes = (
ctypes.wintypes.LPCWSTR, # lpFileName
ctypes.wintypes.DWORD, # dwDesiredAccess
ctypes.wintypes.DWORD, # dwShareMode
LPVOID, # lpSecurityAttributes
ctypes.wintypes.DWORD, # dwCreationDisposition
ctypes.wintypes.DWORD, # dwFlagsAndAttributes
ctypes.wintypes.HANDLE, # hTemplateFile
)
CloseHandle = kernel32.CloseHandle
CloseHandle.restype = ctypes.wintypes.BOOL
CloseHandle.argtypes = (ctypes.wintypes.HANDLE,) # hObject
CancelIoEx = kernel32.CancelIoEx
CancelIoEx.restype = ctypes.wintypes.BOOL
CancelIoEx.errcheck = _errcheck_bool
CancelIoEx.argtypes = (
ctypes.wintypes.HANDLE, # hObject
ctypes.POINTER(OVERLAPPED), # lpOverlapped
)
CreateEvent = kernel32.CreateEventW
CreateEvent.restype = ctypes.wintypes.HANDLE
CreateEvent.errcheck = _errcheck_handle
CreateEvent.argtypes = (
LPVOID, # lpEventAttributes
ctypes.wintypes.BOOL, # bManualReset
ctypes.wintypes.BOOL, # bInitialState
ctypes.wintypes.LPCWSTR, # lpName
)
SetEvent = kernel32.SetEvent
SetEvent.restype = ctypes.wintypes.BOOL
SetEvent.errcheck = _errcheck_bool
SetEvent.argtypes = (ctypes.wintypes.HANDLE,) # hEvent
WaitForSingleObjectEx = kernel32.WaitForSingleObjectEx
WaitForSingleObjectEx.restype = ctypes.wintypes.DWORD
WaitForSingleObjectEx.errcheck = _errcheck_dword
WaitForSingleObjectEx.argtypes = (
ctypes.wintypes.HANDLE, # hObject
ctypes.wintypes.DWORD, # dwMilliseconds
ctypes.wintypes.BOOL, # bAlertable
)
CreateIoCompletionPort = kernel32.CreateIoCompletionPort
CreateIoCompletionPort.restype = ctypes.wintypes.HANDLE
CreateIoCompletionPort.errcheck = _errcheck_handle
CreateIoCompletionPort.argtypes = (
ctypes.wintypes.HANDLE, # FileHandle
ctypes.wintypes.HANDLE, # ExistingCompletionPort
LPVOID, # CompletionKey
ctypes.wintypes.DWORD, # NumberOfConcurrentThreads
)
GetQueuedCompletionStatus = kernel32.GetQueuedCompletionStatus
GetQueuedCompletionStatus.restype = ctypes.wintypes.BOOL
GetQueuedCompletionStatus.errcheck = _errcheck_bool
GetQueuedCompletionStatus.argtypes = (
ctypes.wintypes.HANDLE, # CompletionPort
LPVOID, # lpNumberOfBytesTransferred
LPVOID, # lpCompletionKey
ctypes.POINTER(OVERLAPPED), # lpOverlapped
ctypes.wintypes.DWORD, # dwMilliseconds
)
PostQueuedCompletionStatus = kernel32.PostQueuedCompletionStatus
PostQueuedCompletionStatus.restype = ctypes.wintypes.BOOL
PostQueuedCompletionStatus.errcheck = _errcheck_bool
PostQueuedCompletionStatus.argtypes = (
ctypes.wintypes.HANDLE, # CompletionPort
ctypes.wintypes.DWORD, # lpNumberOfBytesTransferred
ctypes.wintypes.DWORD, # lpCompletionKey
ctypes.POINTER(OVERLAPPED), # lpOverlapped
)
GetFinalPathNameByHandleW = kernel32.GetFinalPathNameByHandleW
GetFinalPathNameByHandleW.restype = ctypes.wintypes.DWORD
GetFinalPathNameByHandleW.errcheck = _errcheck_dword
GetFinalPathNameByHandleW.argtypes = (
ctypes.wintypes.HANDLE, # hFile
ctypes.wintypes.LPWSTR, # lpszFilePath
ctypes.wintypes.DWORD, # cchFilePath
ctypes.wintypes.DWORD, # DWORD
)
class FILE_NOTIFY_INFORMATION(ctypes.Structure):
_fields_ = [
("NextEntryOffset", ctypes.wintypes.DWORD),
("Action", ctypes.wintypes.DWORD),
("FileNameLength", ctypes.wintypes.DWORD),
# ("FileName", (ctypes.wintypes.WCHAR * 1))]
("FileName", (ctypes.c_char * 1)),
]
LPFNI = ctypes.POINTER(FILE_NOTIFY_INFORMATION)
# We don't need to recalculate these flags every time a call is made to
# the win32 API functions.
WATCHDOG_FILE_FLAGS = FILE_FLAG_BACKUP_SEMANTICS
WATCHDOG_FILE_SHARE_FLAGS = reduce(
lambda x, y: x | y,
[
FILE_SHARE_READ,
FILE_SHARE_WRITE,
FILE_SHARE_DELETE,
],
)
WATCHDOG_FILE_NOTIFY_FLAGS = reduce(
lambda x, y: x | y,
[
FILE_NOTIFY_CHANGE_FILE_NAME,
FILE_NOTIFY_CHANGE_DIR_NAME,
FILE_NOTIFY_CHANGE_ATTRIBUTES,
FILE_NOTIFY_CHANGE_SIZE,
FILE_NOTIFY_CHANGE_LAST_WRITE,
FILE_NOTIFY_CHANGE_SECURITY,
FILE_NOTIFY_CHANGE_LAST_ACCESS,
FILE_NOTIFY_CHANGE_CREATION,
],
)
# ReadDirectoryChangesW buffer length.
# To handle cases with lot of changes, this seems the highest safest value we can use.
# Note: it will fail with ERROR_INVALID_PARAMETER when it is greater than 64 KB and
# the application is monitoring a directory over the network.
# This is due to a packet size limitation with the underlying file sharing protocols.
# https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-readdirectorychangesw#remarks
BUFFER_SIZE = 64000
# Buffer length for path-related stuff.
# Introduced to keep the old behavior when we bumped BUFFER_SIZE from 2048 to 64000 in v1.0.0.
PATH_BUFFER_SIZE = 2048
def _parse_event_buffer(readBuffer, nBytes):
results = []
while nBytes > 0:
fni = ctypes.cast(readBuffer, LPFNI)[0]
ptr = ctypes.addressof(fni) + FILE_NOTIFY_INFORMATION.FileName.offset
# filename = ctypes.wstring_at(ptr, fni.FileNameLength)
filename = ctypes.string_at(ptr, fni.FileNameLength)
results.append((fni.Action, filename.decode("utf-16")))
numToSkip = fni.NextEntryOffset
if numToSkip <= 0:
break
readBuffer = readBuffer[numToSkip:]
nBytes -= numToSkip # numToSkip is long. nBytes should be long too.
return results
def _is_observed_path_deleted(handle, path):
# Comparison of observed path and actual path, returned by
# GetFinalPathNameByHandleW. If directory moved to the trash bin, or
# deleted, actual path will not be equal to observed path.
buff = ctypes.create_unicode_buffer(PATH_BUFFER_SIZE)
GetFinalPathNameByHandleW(handle, buff, PATH_BUFFER_SIZE, VOLUME_NAME_NT)
return buff.value != path
def _generate_observed_path_deleted_event():
# Create synthetic event for notify that observed directory is deleted
path = ctypes.create_unicode_buffer(".")
event = FILE_NOTIFY_INFORMATION(
0, FILE_ACTION_DELETED_SELF, len(path), path.value.encode("utf-8")
)
event_size = ctypes.sizeof(event)
buff = ctypes.create_string_buffer(PATH_BUFFER_SIZE)
ctypes.memmove(buff, ctypes.addressof(event), event_size)
return buff, event_size
def get_directory_handle(path):
"""Returns a Windows handle to the specified directory path."""
return CreateFileW(
path,
FILE_LIST_DIRECTORY,
WATCHDOG_FILE_SHARE_FLAGS,
None,
OPEN_EXISTING,
WATCHDOG_FILE_FLAGS,
None,
)
def close_directory_handle(handle):
try:
CancelIoEx(handle, None) # force ReadDirectoryChangesW to return
CloseHandle(handle) # close directory handle
except OSError:
try:
CloseHandle(handle) # close directory handle
except Exception:
return
def read_directory_changes(handle, path, recursive):
"""Read changes to the directory using the specified directory handle.
http://timgolden.me.uk/pywin32-docs/win32file__ReadDirectoryChangesW_meth.html
"""
event_buffer = ctypes.create_string_buffer(BUFFER_SIZE)
nbytes = ctypes.wintypes.DWORD()
try:
ReadDirectoryChangesW(
handle,
ctypes.byref(event_buffer),
len(event_buffer),
recursive,
WATCHDOG_FILE_NOTIFY_FLAGS,
ctypes.byref(nbytes),
None,
None,
)
except OSError as e:
if e.winerror == ERROR_OPERATION_ABORTED:
return [], 0
# Handle the case when the root path is deleted
if _is_observed_path_deleted(handle, path):
return _generate_observed_path_deleted_event()
raise e
return event_buffer.raw, int(nbytes.value)
class WinAPINativeEvent:
def __init__(self, action, src_path):
self.action = action
self.src_path = src_path
@property
def is_added(self):
return self.action == FILE_ACTION_CREATED
@property
def is_removed(self):
return self.action == FILE_ACTION_REMOVED
@property
def is_modified(self):
return self.action == FILE_ACTION_MODIFIED
@property
def is_renamed_old(self):
return self.action == FILE_ACTION_RENAMED_OLD_NAME
@property
def is_renamed_new(self):
return self.action == FILE_ACTION_RENAMED_NEW_NAME
@property
def is_removed_self(self):
return self.action == FILE_ACTION_REMOVED_SELF
def __repr__(self):
return (
f"<{type(self).__name__}: action={self.action}, src_path={self.src_path!r}>"
)
def read_events(handle, path, recursive):
buf, nbytes = read_directory_changes(handle, path, recursive)
events = _parse_event_buffer(buf, nbytes)
return [WinAPINativeEvent(action, src_path) for action, src_path in events]

View File

View File

@ -0,0 +1,334 @@
# Copyright 2011 Yesudeep Mangalapilly <yesudeep@gmail.com>
# Copyright 2012 Google, Inc & contributors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
:module: libs.watchdog.tricks
:synopsis: Utility event handlers.
:author: yesudeep@google.com (Yesudeep Mangalapilly)
:author: contact@tiger-222.fr (Mickaël Schoentgen)
Classes
-------
.. autoclass:: Trick
:members:
:show-inheritance:
.. autoclass:: LoggerTrick
:members:
:show-inheritance:
.. autoclass:: ShellCommandTrick
:members:
:show-inheritance:
.. autoclass:: AutoRestartTrick
:members:
:show-inheritance:
"""
from __future__ import annotations
import functools
import logging
import os
import signal
import subprocess
import sys
import threading
import time
from libs.watchdog.events import EVENT_TYPE_OPENED, PatternMatchingEventHandler
from libs.watchdog.utils import echo
from libs.watchdog.utils.event_debouncer import EventDebouncer
from libs.watchdog.utils.process_watcher import ProcessWatcher
logger = logging.getLogger(__name__)
echo_events = functools.partial(echo.echo, write=lambda msg: logger.info(msg))
class Trick(PatternMatchingEventHandler):
"""Your tricks should subclass this class."""
@classmethod
def generate_yaml(cls):
return f"""- {cls.__module__}.{cls.__name__}:
args:
- argument1
- argument2
kwargs:
patterns:
- "*.py"
- "*.js"
ignore_patterns:
- "version.py"
ignore_directories: false
"""
class LoggerTrick(Trick):
"""A simple trick that does only logs events."""
def on_any_event(self, event):
pass
@echo_events
def on_modified(self, event):
pass
@echo_events
def on_deleted(self, event):
pass
@echo_events
def on_created(self, event):
pass
@echo_events
def on_moved(self, event):
pass
@echo_events
def on_closed(self, event):
pass
@echo_events
def on_opened(self, event):
pass
class ShellCommandTrick(Trick):
"""Executes shell commands in response to matched events."""
def __init__(
self,
shell_command=None,
patterns=None,
ignore_patterns=None,
ignore_directories=False,
wait_for_process=False,
drop_during_process=False,
):
super().__init__(
patterns=patterns,
ignore_patterns=ignore_patterns,
ignore_directories=ignore_directories,
)
self.shell_command = shell_command
self.wait_for_process = wait_for_process
self.drop_during_process = drop_during_process
self.process = None
self._process_watchers = set()
def on_any_event(self, event):
if event.event_type == EVENT_TYPE_OPENED:
# FIXME: see issue #949, and find a way to better handle that scenario
return
from string import Template
if self.drop_during_process and self.is_process_running():
return
object_type = "directory" if event.is_directory else "file"
context = {
"watch_src_path": event.src_path,
"watch_dest_path": "",
"watch_event_type": event.event_type,
"watch_object": object_type,
}
if self.shell_command is None:
if hasattr(event, "dest_path"):
context["dest_path"] = event.dest_path
command = 'echo "${watch_event_type} ${watch_object} from ${watch_src_path} to ${watch_dest_path}"'
else:
command = 'echo "${watch_event_type} ${watch_object} ${watch_src_path}"'
else:
if hasattr(event, "dest_path"):
context["watch_dest_path"] = event.dest_path
command = self.shell_command
command = Template(command).safe_substitute(**context)
self.process = subprocess.Popen(command, shell=True)
if self.wait_for_process:
self.process.wait()
else:
process_watcher = ProcessWatcher(self.process, None)
self._process_watchers.add(process_watcher)
process_watcher.process_termination_callback = functools.partial(
self._process_watchers.discard, process_watcher
)
process_watcher.start()
def is_process_running(self):
return self._process_watchers or (
self.process is not None and self.process.poll() is None
)
class AutoRestartTrick(Trick):
"""Starts a long-running subprocess and restarts it on matched events.
The command parameter is a list of command arguments, such as
`['bin/myserver', '-c', 'etc/myconfig.ini']`.
Call `start()` after creating the Trick. Call `stop()` when stopping
the process.
"""
def __init__(
self,
command,
patterns=None,
ignore_patterns=None,
ignore_directories=False,
stop_signal=signal.SIGINT,
kill_after=10,
debounce_interval_seconds=0,
restart_on_command_exit=True,
):
if kill_after < 0:
raise ValueError("kill_after must be non-negative.")
if debounce_interval_seconds < 0:
raise ValueError("debounce_interval_seconds must be non-negative.")
super().__init__(
patterns=patterns,
ignore_patterns=ignore_patterns,
ignore_directories=ignore_directories,
)
self.command = command
self.stop_signal = stop_signal
self.kill_after = kill_after
self.debounce_interval_seconds = debounce_interval_seconds
self.restart_on_command_exit = restart_on_command_exit
self.process = None
self.process_watcher = None
self.event_debouncer = None
self.restart_count = 0
self._is_process_stopping = False
self._is_trick_stopping = False
self._stopping_lock = threading.RLock()
def start(self):
if self.debounce_interval_seconds:
self.event_debouncer = EventDebouncer(
debounce_interval_seconds=self.debounce_interval_seconds,
events_callback=lambda events: self._restart_process(),
)
self.event_debouncer.start()
self._start_process()
def stop(self):
# Ensure the body of the function is only run once.
with self._stopping_lock:
if self._is_trick_stopping:
return
self._is_trick_stopping = True
process_watcher = self.process_watcher
if self.event_debouncer is not None:
self.event_debouncer.stop()
self._stop_process()
# Don't leak threads: Wait for background threads to stop.
if self.event_debouncer is not None:
self.event_debouncer.join()
if process_watcher is not None:
process_watcher.join()
def _start_process(self):
if self._is_trick_stopping:
return
# windows doesn't have setsid
self.process = subprocess.Popen(
self.command, preexec_fn=getattr(os, "setsid", None)
)
if self.restart_on_command_exit:
self.process_watcher = ProcessWatcher(self.process, self._restart_process)
self.process_watcher.start()
def _stop_process(self):
# Ensure the body of the function is not run in parallel in different threads.
with self._stopping_lock:
if self._is_process_stopping:
return
self._is_process_stopping = True
try:
if self.process_watcher is not None:
self.process_watcher.stop()
self.process_watcher = None
if self.process is not None:
try:
kill_process(self.process.pid, self.stop_signal)
except OSError:
# Process is already gone
pass
else:
kill_time = time.time() + self.kill_after
while time.time() < kill_time:
if self.process.poll() is not None:
break
time.sleep(0.25)
else:
try:
kill_process(self.process.pid, 9)
except OSError:
# Process is already gone
pass
self.process = None
finally:
self._is_process_stopping = False
@echo_events
def on_any_event(self, event):
if event.event_type == EVENT_TYPE_OPENED:
# FIXME: see issue #949, and find a way to better handle that scenario
return
if self.event_debouncer is not None:
self.event_debouncer.handle_event(event)
else:
self._restart_process()
def _restart_process(self):
if self._is_trick_stopping:
return
self._stop_process()
self._start_process()
self.restart_count += 1
if not sys.platform.startswith("win"):
def kill_process(pid, stop_signal):
os.killpg(os.getpgid(pid), stop_signal)
else:
def kill_process(pid, stop_signal):
os.kill(pid, stop_signal)

View File

@ -0,0 +1,150 @@
# Copyright 2011 Yesudeep Mangalapilly <yesudeep@gmail.com>
# Copyright 2012 Google, Inc & contributors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
:module: libs.watchdog.utils
:synopsis: Utility classes and functions.
:author: yesudeep@google.com (Yesudeep Mangalapilly)
:author: contact@tiger-222.fr (Mickaël Schoentgen)
Classes
-------
.. autoclass:: BaseThread
:members:
:show-inheritance:
:inherited-members:
"""
from __future__ import annotations
import sys
import threading
from typing import TYPE_CHECKING
class UnsupportedLibc(Exception):
pass
class WatchdogShutdown(Exception):
"""
Semantic exception used to signal an external shutdown event.
"""
pass
class BaseThread(threading.Thread):
"""Convenience class for creating stoppable threads."""
def __init__(self):
threading.Thread.__init__(self)
if hasattr(self, "daemon"):
self.daemon = True
else:
self.setDaemon(True)
self._stopped_event = threading.Event()
@property
def stopped_event(self):
return self._stopped_event
def should_keep_running(self):
"""Determines whether the thread should continue running."""
return not self._stopped_event.is_set()
def on_thread_stop(self):
"""Override this method instead of :meth:`stop()`.
:meth:`stop()` calls this method.
This method is called immediately after the thread is signaled to stop.
"""
pass
def stop(self):
"""Signals the thread to stop."""
self._stopped_event.set()
self.on_thread_stop()
def on_thread_start(self):
"""Override this method instead of :meth:`start()`. :meth:`start()`
calls this method.
This method is called right before this thread is started and this
objects run() method is invoked.
"""
pass
def start(self):
self.on_thread_start()
threading.Thread.start(self)
def load_module(module_name):
"""Imports a module given its name and returns a handle to it."""
try:
__import__(module_name)
except ImportError:
raise ImportError(f"No module named {module_name}")
return sys.modules[module_name]
def load_class(dotted_path):
"""Loads and returns a class definition provided a dotted path
specification the last part of the dotted path is the class name
and there is at least one module name preceding the class name.
Notes:
You will need to ensure that the module you are trying to load
exists in the Python path.
Examples:
- module.name.ClassName # Provided module.name is in the Python path.
- module.ClassName # Provided module is in the Python path.
What won't work:
- ClassName
- modle.name.ClassName # Typo in module name.
- module.name.ClasNam # Typo in classname.
"""
dotted_path_split = dotted_path.split(".")
if len(dotted_path_split) <= 1:
raise ValueError(
f"Dotted module path {dotted_path} must contain a module name and a classname"
)
klass_name = dotted_path_split[-1]
module_name = ".".join(dotted_path_split[:-1])
module = load_module(module_name)
if hasattr(module, klass_name):
return getattr(module, klass_name)
# Finally create and return an instance of the class
# return klass(*args, **kwargs)
else:
raise AttributeError(
f"Module {module_name} does not have class attribute {klass_name}"
)
if TYPE_CHECKING or sys.version_info >= (3, 8):
# using `as` to explicitly re-export this since this is a compatibility layer
from typing import Protocol as Protocol
else:
# Provide a dummy Protocol class when not available from stdlib. Should be used
# only for hinting. This could be had from typing_protocol, but not worth adding
# the _first_ dependency just for this.
class Protocol:
...

View File

@ -0,0 +1,102 @@
# Copyright 2011 Yesudeep Mangalapilly <yesudeep@gmail.com>
# Copyright 2012 Google, Inc & contributors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Utility collections or "bricks".
:module: libs.watchdog.utils.bricks
:author: yesudeep@google.com (Yesudeep Mangalapilly)
:author: lalinsky@gmail.com (Lukáš Lalinský)
:author: python@rcn.com (Raymond Hettinger)
:author: contact@tiger-222.fr (Mickaël Schoentgen)
Classes
=======
.. autoclass:: OrderedSetQueue
:members:
:show-inheritance:
:inherited-members:
.. autoclass:: OrderedSet
"""
from __future__ import annotations
import queue
class SkipRepeatsQueue(queue.Queue):
"""Thread-safe implementation of an special queue where a
put of the last-item put'd will be dropped.
The implementation leverages locking already implemented in the base class
redefining only the primitives.
Queued items must be immutable and hashable so that they can be used
as dictionary keys. You must implement **only read-only properties** and
the :meth:`Item.__hash__()`, :meth:`Item.__eq__()`, and
:meth:`Item.__ne__()` methods for items to be hashable.
An example implementation follows::
class Item:
def __init__(self, a, b):
self._a = a
self._b = b
@property
def a(self):
return self._a
@property
def b(self):
return self._b
def _key(self):
return (self._a, self._b)
def __eq__(self, item):
return self._key() == item._key()
def __ne__(self, item):
return self._key() != item._key()
def __hash__(self):
return hash(self._key())
based on the OrderedSetQueue below
"""
def _init(self, maxsize):
super()._init(maxsize)
self._last_item = None
def _put(self, item):
if self._last_item is None or item != self._last_item:
super()._put(item)
self._last_item = item
else:
# `put` increments `unfinished_tasks` even if we did not put
# anything into the queue here
self.unfinished_tasks -= 1
def _get(self):
item = super()._get()
if item is self._last_item:
self._last_item = None
return item

View File

@ -0,0 +1,85 @@
# Copyright 2014 Thomas Amland <thomas.amland@gmail.com>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import threading
import time
from collections import deque
from typing import Callable, Deque, Generic, Optional, Tuple, TypeVar
T = TypeVar("T")
class DelayedQueue(Generic[T]):
def __init__(self, delay):
self.delay_sec = delay
self._lock = threading.Lock()
self._not_empty = threading.Condition(self._lock)
self._queue: Deque[Tuple[T, float, bool]] = deque()
self._closed = False
def put(self, element: T, delay: bool = False) -> None:
"""Add element to queue."""
self._lock.acquire()
self._queue.append((element, time.time(), delay))
self._not_empty.notify()
self._lock.release()
def close(self):
"""Close queue, indicating no more items will be added."""
self._closed = True
# Interrupt the blocking _not_empty.wait() call in get
self._not_empty.acquire()
self._not_empty.notify()
self._not_empty.release()
def get(self) -> Optional[T]:
"""Remove and return an element from the queue, or this queue has been
closed raise the Closed exception.
"""
while True:
# wait for element to be added to queue
self._not_empty.acquire()
while len(self._queue) == 0 and not self._closed:
self._not_empty.wait()
if self._closed:
self._not_empty.release()
return None
head, insert_time, delay = self._queue[0]
self._not_empty.release()
# wait for delay if required
if delay:
time_left = insert_time + self.delay_sec - time.time()
while time_left > 0:
time.sleep(time_left)
time_left = insert_time + self.delay_sec - time.time()
# return element if it's still in the queue
with self._lock:
if len(self._queue) > 0 and self._queue[0][0] is head:
self._queue.popleft()
return head
def remove(self, predicate: Callable[[T], bool]) -> Optional[T]:
"""Remove and return the first items for which predicate is True,
ignoring delay."""
with self._lock:
for i, (elem, t, delay) in enumerate(self._queue):
if predicate(elem):
del self._queue[i]
return elem
return None

View File

@ -0,0 +1,375 @@
# Copyright 2011 Yesudeep Mangalapilly <yesudeep@gmail.com>
# Copyright 2012 Google, Inc & contributors.
# Copyright 2014 Thomas Amland <thomas.amland@gmail.com>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
:module: libs.watchdog.utils.dirsnapshot
:synopsis: Directory snapshots and comparison.
:author: yesudeep@google.com (Yesudeep Mangalapilly)
:author: contact@tiger-222.fr (Mickaël Schoentgen)
.. ADMONITION:: Where are the moved events? They "disappeared"
This implementation does not take partition boundaries
into consideration. It will only work when the directory
tree is entirely on the same file system. More specifically,
any part of the code that depends on inode numbers can
break if partition boundaries are crossed. In these cases,
the snapshot diff will represent file/directory movement as
created and deleted events.
Classes
-------
.. autoclass:: DirectorySnapshot
:members:
:show-inheritance:
.. autoclass:: DirectorySnapshotDiff
:members:
:show-inheritance:
.. autoclass:: EmptyDirectorySnapshot
:members:
:show-inheritance:
"""
from __future__ import annotations
import errno
import os
from stat import S_ISDIR
class DirectorySnapshotDiff:
"""
Compares two directory snapshots and creates an object that represents
the difference between the two snapshots.
:param ref:
The reference directory snapshot.
:type ref:
:class:`DirectorySnapshot`
:param snapshot:
The directory snapshot which will be compared
with the reference snapshot.
:type snapshot:
:class:`DirectorySnapshot`
:param ignore_device:
A boolean indicating whether to ignore the device id or not.
By default, a file may be uniquely identified by a combination of its first
inode and its device id. The problem is that the device id may (or may not)
change between system boots. This problem would cause the DirectorySnapshotDiff
to think a file has been deleted and created again but it would be the
exact same file.
Set to True only if you are sure you will always use the same device.
:type ignore_device:
:class:`bool`
"""
def __init__(self, ref, snapshot, ignore_device=False):
created = snapshot.paths - ref.paths
deleted = ref.paths - snapshot.paths
if ignore_device:
def get_inode(directory, full_path):
return directory.inode(full_path)[0]
else:
def get_inode(directory, full_path):
return directory.inode(full_path)
# check that all unchanged paths have the same inode
for path in ref.paths & snapshot.paths:
if get_inode(ref, path) != get_inode(snapshot, path):
created.add(path)
deleted.add(path)
# find moved paths
moved = set()
for path in set(deleted):
inode = ref.inode(path)
new_path = snapshot.path(inode)
if new_path:
# file is not deleted but moved
deleted.remove(path)
moved.add((path, new_path))
for path in set(created):
inode = snapshot.inode(path)
old_path = ref.path(inode)
if old_path:
created.remove(path)
moved.add((old_path, path))
# find modified paths
# first check paths that have not moved
modified = set()
for path in ref.paths & snapshot.paths:
if get_inode(ref, path) == get_inode(snapshot, path):
if ref.mtime(path) != snapshot.mtime(path) or ref.size(
path
) != snapshot.size(path):
modified.add(path)
for old_path, new_path in moved:
if ref.mtime(old_path) != snapshot.mtime(new_path) or ref.size(
old_path
) != snapshot.size(new_path):
modified.add(old_path)
self._dirs_created = [path for path in created if snapshot.isdir(path)]
self._dirs_deleted = [path for path in deleted if ref.isdir(path)]
self._dirs_modified = [path for path in modified if ref.isdir(path)]
self._dirs_moved = [(frm, to) for (frm, to) in moved if ref.isdir(frm)]
self._files_created = list(created - set(self._dirs_created))
self._files_deleted = list(deleted - set(self._dirs_deleted))
self._files_modified = list(modified - set(self._dirs_modified))
self._files_moved = list(moved - set(self._dirs_moved))
def __str__(self):
return self.__repr__()
def __repr__(self):
fmt = (
"<{0} files(created={1}, deleted={2}, modified={3}, moved={4}),"
" folders(created={5}, deleted={6}, modified={7}, moved={8})>"
)
return fmt.format(
type(self).__name__,
len(self._files_created),
len(self._files_deleted),
len(self._files_modified),
len(self._files_moved),
len(self._dirs_created),
len(self._dirs_deleted),
len(self._dirs_modified),
len(self._dirs_moved),
)
@property
def files_created(self):
"""List of files that were created."""
return self._files_created
@property
def files_deleted(self):
"""List of files that were deleted."""
return self._files_deleted
@property
def files_modified(self):
"""List of files that were modified."""
return self._files_modified
@property
def files_moved(self):
"""
List of files that were moved.
Each event is a two-tuple the first item of which is the path
that has been renamed to the second item in the tuple.
"""
return self._files_moved
@property
def dirs_modified(self):
"""
List of directories that were modified.
"""
return self._dirs_modified
@property
def dirs_moved(self):
"""
List of directories that were moved.
Each event is a two-tuple the first item of which is the path
that has been renamed to the second item in the tuple.
"""
return self._dirs_moved
@property
def dirs_deleted(self):
"""
List of directories that were deleted.
"""
return self._dirs_deleted
@property
def dirs_created(self):
"""
List of directories that were created.
"""
return self._dirs_created
class DirectorySnapshot:
"""
A snapshot of stat information of files in a directory.
:param path:
The directory path for which a snapshot should be taken.
:type path:
``str``
:param recursive:
``True`` if the entire directory tree should be included in the
snapshot; ``False`` otherwise.
:type recursive:
``bool``
:param stat:
Use custom stat function that returns a stat structure for path.
Currently only st_dev, st_ino, st_mode and st_mtime are needed.
A function taking a ``path`` as argument which will be called
for every entry in the directory tree.
:param listdir:
Use custom listdir function. For details see ``os.scandir``.
"""
def __init__(self, path, recursive=True, stat=os.stat, listdir=os.scandir):
self.recursive = recursive
self.stat = stat
self.listdir = listdir
self._stat_info = {}
self._inode_to_path = {}
st = self.stat(path)
self._stat_info[path] = st
self._inode_to_path[(st.st_ino, st.st_dev)] = path
for p, st in self.walk(path):
i = (st.st_ino, st.st_dev)
self._inode_to_path[i] = p
self._stat_info[p] = st
def walk(self, root):
try:
paths = [os.path.join(root, entry.name) for entry in self.listdir(root)]
except OSError as e:
# Directory may have been deleted between finding it in the directory
# list of its parent and trying to delete its contents. If this
# happens we treat it as empty. Likewise if the directory was replaced
# with a file of the same name (less likely, but possible).
if e.errno in (errno.ENOENT, errno.ENOTDIR, errno.EINVAL):
return
else:
raise
entries = []
for p in paths:
try:
entry = (p, self.stat(p))
entries.append(entry)
yield entry
except OSError:
continue
if self.recursive:
for path, st in entries:
try:
if S_ISDIR(st.st_mode):
for entry in self.walk(path):
yield entry
except PermissionError:
pass
@property
def paths(self):
"""
Set of file/directory paths in the snapshot.
"""
return set(self._stat_info.keys())
def path(self, id):
"""
Returns path for id. None if id is unknown to this snapshot.
"""
return self._inode_to_path.get(id)
def inode(self, path):
"""Returns an id for path."""
st = self._stat_info[path]
return (st.st_ino, st.st_dev)
def isdir(self, path):
return S_ISDIR(self._stat_info[path].st_mode)
def mtime(self, path):
return self._stat_info[path].st_mtime
def size(self, path):
return self._stat_info[path].st_size
def stat_info(self, path):
"""
Returns a stat information object for the specified path from
the snapshot.
Attached information is subject to change. Do not use unless
you specify `stat` in constructor. Use :func:`inode`, :func:`mtime`,
:func:`isdir` instead.
:param path:
The path for which stat information should be obtained
from a snapshot.
"""
return self._stat_info[path]
def __sub__(self, previous_dirsnap):
"""Allow subtracting a DirectorySnapshot object instance from
another.
:returns:
A :class:`DirectorySnapshotDiff` object.
"""
return DirectorySnapshotDiff(previous_dirsnap, self)
def __str__(self):
return self.__repr__()
def __repr__(self):
return str(self._stat_info)
class EmptyDirectorySnapshot:
"""Class to implement an empty snapshot. This is used together with
DirectorySnapshot and DirectorySnapshotDiff in order to get all the files/folders
in the directory as created.
"""
@staticmethod
def path(_):
"""Mock up method to return the path of the received inode. As the snapshot
is intended to be empty, it always returns None.
:returns:
None.
"""
return None
@property
def paths(self):
"""Mock up method to return a set of file/directory paths in the snapshot. As
the snapshot is intended to be empty, it always returns an empty set.
:returns:
An empty set.
"""
return set()

View File

@ -0,0 +1,167 @@
# echo.py: Tracing function calls using Python decorators.
#
# Written by Thomas Guest <tag@wordaligned.org>
# Please see http://wordaligned.org/articles/echo
#
# Place into the public domain.
""" Echo calls made to functions and methods in a module.
"Echoing" a function call means printing out the name of the function
and the values of its arguments before making the call (which is more
commonly referred to as "tracing", but Python already has a trace module).
Example: to echo calls made to functions in "my_module" do:
import echo
import my_module
echo.echo_module(my_module)
Example: to echo calls made to functions in "my_module.my_class" do:
echo.echo_class(my_module.my_class)
Alternatively, echo.echo can be used to decorate functions. Calls to the
decorated function will be echoed.
Example:
@echo.echo
def my_function(args):
pass
"""
from __future__ import annotations
import inspect
import sys
def name(item):
"""Return an item's name."""
return item.__name__
def is_classmethod(instancemethod, klass):
"""Determine if an instancemethod is a classmethod."""
return inspect.ismethod(instancemethod) and instancemethod.__self__ is klass
def is_static_method(method, klass):
"""Returns True if method is an instance method of klass."""
return next(
(
isinstance(c.__dict__[name(method)], staticmethod)
for c in klass.mro()
if name(method) in c.__dict__
),
False,
)
def is_class_private_name(name):
"""Determine if a name is a class private name."""
# Exclude system defined names such as __init__, __add__ etc
return name.startswith("__") and not name.endswith("__")
def method_name(method):
"""Return a method's name.
This function returns the name the method is accessed by from
outside the class (i.e. it prefixes "private" methods appropriately).
"""
mname = name(method)
if is_class_private_name(mname):
mname = f"_{name(method.__self__.__class__)}{mname}"
return mname
def format_arg_value(arg_val):
"""Return a string representing a (name, value) pair.
>>> format_arg_value(('x', (1, 2, 3)))
'x=(1, 2, 3)'
"""
arg, val = arg_val
return f"{arg}={val!r}"
def echo(fn, write=sys.stdout.write):
"""Echo calls to a function.
Returns a decorated version of the input function which "echoes" calls
made to it by writing out the function's name and the arguments it was
called with.
"""
import functools
# Unpack function's arg count, arg names, arg defaults
code = fn.__code__
argcount = code.co_argcount
argnames = code.co_varnames[:argcount]
fn_defaults = fn.__defaults__ or []
argdefs = dict(list(zip(argnames[-len(fn_defaults) :], fn_defaults)))
@functools.wraps(fn)
def wrapped(*v, **k):
# Collect function arguments by chaining together positional,
# defaulted, extra positional and keyword arguments.
positional = list(map(format_arg_value, list(zip(argnames, v))))
defaulted = [
format_arg_value((a, argdefs[a])) for a in argnames[len(v) :] if a not in k
]
nameless = list(map(repr, v[argcount:]))
keyword = list(map(format_arg_value, list(k.items())))
args = positional + defaulted + nameless + keyword
write(f"{name(fn)}({', '.join(args)})\n")
return fn(*v, **k)
return wrapped
def echo_instancemethod(klass, method, write=sys.stdout.write):
"""Change an instancemethod so that calls to it are echoed.
Replacing a classmethod is a little more tricky.
See: http://www.python.org/doc/current/ref/types.html
"""
mname = method_name(method)
never_echo = (
"__str__",
"__repr__",
) # Avoid recursion printing method calls
if mname in never_echo:
pass
elif is_classmethod(method, klass):
setattr(klass, mname, classmethod(echo(method.__func__, write)))
else:
setattr(klass, mname, echo(method, write))
def echo_class(klass, write=sys.stdout.write):
"""Echo calls to class methods and static functions"""
for _, method in inspect.getmembers(klass, inspect.ismethod):
# In python 3 only class methods are returned here
echo_instancemethod(klass, method, write)
for _, fn in inspect.getmembers(klass, inspect.isfunction):
if is_static_method(fn, klass):
setattr(klass, name(fn), staticmethod(echo(fn, write)))
else:
# It's not a class or a static method, so it must be an instance method.
echo_instancemethod(klass, fn, write)
def echo_module(mod, write=sys.stdout.write):
"""Echo calls to functions and methods in a module."""
for fname, fn in inspect.getmembers(mod, inspect.isfunction):
setattr(mod, fname, echo(fn, write))
for _, klass in inspect.getmembers(mod, inspect.isclass):
echo_class(klass, write)
if __name__ == "__main__":
import doctest
optionflags = doctest.ELLIPSIS
doctest.testfile("echoexample.txt", optionflags=optionflags)
doctest.testmod(optionflags=optionflags)

View File

@ -0,0 +1,56 @@
from __future__ import annotations
import logging
import threading
from libs.watchdog.utils import BaseThread
logger = logging.getLogger(__name__)
class EventDebouncer(BaseThread):
"""Background thread for debouncing event handling.
When an event is received, wait until the configured debounce interval
passes before calling the callback. If additional events are received
before the interval passes, reset the timer and keep waiting. When the
debouncing interval passes, the callback will be called with a list of
events in the order in which they were received.
"""
def __init__(self, debounce_interval_seconds, events_callback):
super().__init__()
self.debounce_interval_seconds = debounce_interval_seconds
self.events_callback = events_callback
self._events = []
self._cond = threading.Condition()
def handle_event(self, event):
with self._cond:
self._events.append(event)
self._cond.notify()
def stop(self):
with self._cond:
super().stop()
self._cond.notify()
def run(self):
with self._cond:
while True:
# Wait for first event (or shutdown).
self._cond.wait()
if self.debounce_interval_seconds:
# Wait for additional events (or shutdown) until the debounce interval passes.
while self.should_keep_running():
if not self._cond.wait(timeout=self.debounce_interval_seconds):
break
if not self.should_keep_running():
break
events = self._events
self._events = []
self.events_callback(events)

View File

@ -0,0 +1,95 @@
# patterns.py: Common wildcard searching/filtering functionality for files.
#
# Copyright (C) 2010 Yesudeep Mangalapilly <yesudeep@gmail.com>
#
# Written by Boris Staletic <boris.staletic@gmail.com>
from __future__ import annotations
# Non-pure path objects are only allowed on their respective OS's.
# Thus, these utilities require "pure" path objects that don't access the filesystem.
# Since pathlib doesn't have a `case_sensitive` parameter, we have to approximate it
# by converting input paths to `PureWindowsPath` and `PurePosixPath` where:
# - `PureWindowsPath` is always case-insensitive.
# - `PurePosixPath` is always case-sensitive.
# Reference: https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.match
from pathlib import PurePosixPath, PureWindowsPath
def _match_path(path, included_patterns, excluded_patterns, case_sensitive):
"""Internal function same as :func:`match_path` but does not check arguments."""
if case_sensitive:
path = PurePosixPath(path)
else:
included_patterns = {pattern.lower() for pattern in included_patterns}
excluded_patterns = {pattern.lower() for pattern in excluded_patterns}
path = PureWindowsPath(path)
common_patterns = included_patterns & excluded_patterns
if common_patterns:
raise ValueError(
"conflicting patterns `{}` included and excluded".format(common_patterns)
)
return any(path.match(p) for p in included_patterns) and not any(
path.match(p) for p in excluded_patterns
)
def filter_paths(
paths, included_patterns=None, excluded_patterns=None, case_sensitive=True
):
"""
Filters from a set of paths based on acceptable patterns and
ignorable patterns.
:param pathnames:
A list of path names that will be filtered based on matching and
ignored patterns.
:param included_patterns:
Allow filenames matching wildcard patterns specified in this list.
If no pattern list is specified, ["*"] is used as the default pattern,
which matches all files.
:param excluded_patterns:
Ignores filenames matching wildcard patterns specified in this list.
If no pattern list is specified, no files are ignored.
:param case_sensitive:
``True`` if matching should be case-sensitive; ``False`` otherwise.
:returns:
A list of pathnames that matched the allowable patterns and passed
through the ignored patterns.
"""
included = ["*"] if included_patterns is None else included_patterns
excluded = [] if excluded_patterns is None else excluded_patterns
for path in paths:
if _match_path(path, set(included), set(excluded), case_sensitive):
yield path
def match_any_paths(
paths, included_patterns=None, excluded_patterns=None, case_sensitive=True
):
"""
Matches from a set of paths based on acceptable patterns and
ignorable patterns.
:param pathnames:
A list of path names that will be filtered based on matching and
ignored patterns.
:param included_patterns:
Allow filenames matching wildcard patterns specified in this list.
If no pattern list is specified, ["*"] is used as the default pattern,
which matches all files.
:param excluded_patterns:
Ignores filenames matching wildcard patterns specified in this list.
If no pattern list is specified, no files are ignored.
:param case_sensitive:
``True`` if matching should be case-sensitive; ``False`` otherwise.
:returns:
``True`` if any of the paths matches; ``False`` otherwise.
"""
included = ["*"] if included_patterns is None else included_patterns
excluded = [] if excluded_patterns is None else excluded_patterns
for path in paths:
if _match_path(path, set(included), set(excluded), case_sensitive):
return True
return False

View File

@ -0,0 +1,57 @@
# Copyright 2011 Yesudeep Mangalapilly <yesudeep@gmail.com>
# Copyright 2012 Google, Inc & contributors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import sys
PLATFORM_WINDOWS = "windows"
PLATFORM_LINUX = "linux"
PLATFORM_BSD = "bsd"
PLATFORM_DARWIN = "darwin"
PLATFORM_UNKNOWN = "unknown"
def get_platform_name():
if sys.platform.startswith("win"):
return PLATFORM_WINDOWS
elif sys.platform.startswith("darwin"):
return PLATFORM_DARWIN
elif sys.platform.startswith("linux"):
return PLATFORM_LINUX
elif sys.platform.startswith(("dragonfly", "freebsd", "netbsd", "openbsd", "bsd")):
return PLATFORM_BSD
else:
return PLATFORM_UNKNOWN
__platform__ = get_platform_name()
def is_linux():
return __platform__ == PLATFORM_LINUX
def is_bsd():
return __platform__ == PLATFORM_BSD
def is_darwin():
return __platform__ == PLATFORM_DARWIN
def is_windows():
return __platform__ == PLATFORM_WINDOWS

View File

@ -0,0 +1,26 @@
from __future__ import annotations
import logging
from libs.watchdog.utils import BaseThread
logger = logging.getLogger(__name__)
class ProcessWatcher(BaseThread):
def __init__(self, popen_obj, process_termination_callback):
super().__init__()
self.popen_obj = popen_obj
self.process_termination_callback = process_termination_callback
def run(self):
while True:
if self.popen_obj.poll() is not None:
break
if self.stopped_event.wait(timeout=0.1):
return
try:
self.process_termination_callback()
except Exception:
logger.exception("Error calling process termination callback")

View File

@ -0,0 +1,27 @@
# Copyright 2011 Yesudeep Mangalapilly <yesudeep@gmail.com>
# Copyright 2012 Google, Inc & contributors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
# When updating this version number, please update the
# ``docs/source/global.rst.inc`` file as well.
VERSION_MAJOR = 3
VERSION_MINOR = 0
VERSION_BUILD = 0
VERSION_INFO = (VERSION_MAJOR, VERSION_MINOR, VERSION_BUILD)
VERSION_STRING = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}"
__version__ = VERSION_INFO

808
src/libs/watchdog/watchmedo.py Executable file
View File

@ -0,0 +1,808 @@
#
# Copyright 2011 Yesudeep Mangalapilly <yesudeep@gmail.com>
# Copyright 2012 Google, Inc & contributors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
:module: libs.watchdog.watchmedo
:author: yesudeep@google.com (Yesudeep Mangalapilly)
:author: contact@tiger-222.fr (Mickaël Schoentgen)
:synopsis: ``watchmedo`` shell script utility.
"""
from __future__ import annotations
import errno
import logging
import os
import os.path
import sys
import time
from argparse import ArgumentParser, RawDescriptionHelpFormatter
from io import StringIO
from textwrap import dedent
from typing import TYPE_CHECKING
from libs.watchdog.observers.api import BaseObserverSubclassCallable
from libs.watchdog.utils import WatchdogShutdown, load_class
from libs.watchdog.version import VERSION_STRING
logging.basicConfig(level=logging.INFO)
CONFIG_KEY_TRICKS = "tricks"
CONFIG_KEY_PYTHON_PATH = "python-path"
class HelpFormatter(RawDescriptionHelpFormatter):
"""A nicer help formatter.
Help for arguments can be indented and contain new lines.
It will be de-dented and arguments in the help
will be separated by a blank line for better readability.
Source: https://github.com/httpie/httpie/blob/2423f89/httpie/cli/argparser.py#L31
"""
def __init__(self, *args, max_help_position=6, **kwargs):
# A smaller indent for args help.
kwargs["max_help_position"] = max_help_position
super().__init__(*args, **kwargs)
def _split_lines(self, text, width):
text = dedent(text).strip() + "\n\n"
return text.splitlines()
epilog = """\
Copyright 2011 Yesudeep Mangalapilly <yesudeep@gmail.com>.
Copyright 2012 Google, Inc & contributors.
Licensed under the terms of the Apache license, version 2.0. Please see
LICENSE in the source code for more information."""
cli = ArgumentParser(epilog=epilog, formatter_class=HelpFormatter)
cli.add_argument("--version", action="version", version=VERSION_STRING)
subparsers = cli.add_subparsers(dest="top_command")
command_parsers = {}
def argument(*name_or_flags, **kwargs):
"""Convenience function to properly format arguments to pass to the
command decorator.
"""
return list(name_or_flags), kwargs
def command(args=[], parent=subparsers, cmd_aliases=[]):
"""Decorator to define a new command in a sanity-preserving way.
The function will be stored in the ``func`` variable when the parser
parses arguments so that it can be called directly like so::
>>> args = cli.parse_args()
>>> args.func(args)
"""
def decorator(func):
name = func.__name__.replace("_", "-")
desc = dedent(func.__doc__)
parser = parent.add_parser(
name, description=desc, aliases=cmd_aliases, formatter_class=HelpFormatter
)
command_parsers[name] = parser
verbosity_group = parser.add_mutually_exclusive_group()
verbosity_group.add_argument(
"-q", "--quiet", dest="verbosity", action="append_const", const=-1
)
verbosity_group.add_argument(
"-v", "--verbose", dest="verbosity", action="append_const", const=1
)
for arg in args:
parser.add_argument(*arg[0], **arg[1])
parser.set_defaults(func=func)
return func
return decorator
def path_split(pathname_spec, separator=os.pathsep):
"""
Splits a pathname specification separated by an OS-dependent separator.
:param pathname_spec:
The pathname specification.
:param separator:
(OS Dependent) `:` on Unix and `;` on Windows or user-specified.
"""
return list(pathname_spec.split(separator))
def add_to_sys_path(pathnames, index=0):
"""
Adds specified paths at specified index into the sys.path list.
:param paths:
A list of paths to add to the sys.path
:param index:
(Default 0) The index in the sys.path list where the paths will be
added.
"""
for pathname in pathnames[::-1]:
sys.path.insert(index, pathname)
def load_config(tricks_file_pathname):
"""
Loads the YAML configuration from the specified file.
:param tricks_file_path:
The path to the tricks configuration file.
:returns:
A dictionary of configuration information.
"""
import yaml
with open(tricks_file_pathname, "rb") as f:
return yaml.safe_load(f.read())
def parse_patterns(patterns_spec, ignore_patterns_spec, separator=";"):
"""
Parses pattern argument specs and returns a two-tuple of
(patterns, ignore_patterns).
"""
patterns = patterns_spec.split(separator)
ignore_patterns = ignore_patterns_spec.split(separator)
if ignore_patterns == [""]:
ignore_patterns = []
return (patterns, ignore_patterns)
def observe_with(observer, event_handler, pathnames, recursive):
"""
Single observer thread with a scheduled path and event handler.
:param observer:
The observer thread.
:param event_handler:
Event handler which will be called in response to file system events.
:param pathnames:
A list of pathnames to monitor.
:param recursive:
``True`` if recursive; ``False`` otherwise.
"""
for pathname in set(pathnames):
observer.schedule(event_handler, pathname, recursive)
observer.start()
try:
while True:
time.sleep(1)
except WatchdogShutdown:
observer.stop()
observer.join()
def schedule_tricks(observer, tricks, pathname, recursive):
"""
Schedules tricks with the specified observer and for the given watch
path.
:param observer:
The observer thread into which to schedule the trick and watch.
:param tricks:
A list of tricks.
:param pathname:
A path name which should be watched.
:param recursive:
``True`` if recursive; ``False`` otherwise.
"""
for trick in tricks:
for name, value in list(trick.items()):
TrickClass = load_class(name)
handler = TrickClass(**value)
trick_pathname = getattr(handler, "source_directory", None) or pathname
observer.schedule(handler, trick_pathname, recursive)
@command(
[
argument("files", nargs="*", help="perform tricks from given file"),
argument(
"--python-path",
default=".",
help=f"Paths separated by {os.pathsep!r} to add to the Python path.",
),
argument(
"--interval",
"--timeout",
dest="timeout",
default=1.0,
type=float,
help="Use this as the polling interval/blocking timeout (in seconds).",
),
argument(
"--recursive",
action="store_true",
default=True,
help="Recursively monitor paths (defaults to True).",
),
argument(
"--debug-force-polling", action="store_true", help="[debug] Forces polling."
),
argument(
"--debug-force-kqueue",
action="store_true",
help="[debug] Forces BSD kqueue(2).",
),
argument(
"--debug-force-winapi",
action="store_true",
help="[debug] Forces Windows API.",
),
argument(
"--debug-force-fsevents",
action="store_true",
help="[debug] Forces macOS FSEvents.",
),
argument(
"--debug-force-inotify",
action="store_true",
help="[debug] Forces Linux inotify(7).",
),
],
cmd_aliases=["tricks"],
)
def tricks_from(args):
"""
Command to execute tricks from a tricks configuration file.
"""
Observer: BaseObserverSubclassCallable
if args.debug_force_polling:
from libs.watchdog.observers.polling import PollingObserver as Observer
elif args.debug_force_kqueue:
from libs.watchdog.observers.kqueue import KqueueObserver as Observer
elif (not TYPE_CHECKING and args.debug_force_winapi) or (TYPE_CHECKING and sys.platform.startswith("win")):
from libs.watchdog.observers.read_directory_changes import WindowsApiObserver as Observer
elif args.debug_force_inotify:
from libs.watchdog.observers.inotify import InotifyObserver as Observer
elif args.debug_force_fsevents:
from libs.watchdog.observers.fsevents import FSEventsObserver as Observer
else:
# Automatically picks the most appropriate observer for the platform
# on which it is running.
from libs.watchdog.observers import Observer
add_to_sys_path(path_split(args.python_path))
observers = []
for tricks_file in args.files:
observer = Observer(timeout=args.timeout)
if not os.path.exists(tricks_file):
raise OSError(errno.ENOENT, os.strerror(errno.ENOENT), tricks_file)
config = load_config(tricks_file)
try:
tricks = config[CONFIG_KEY_TRICKS]
except KeyError:
raise KeyError(
f"No {CONFIG_KEY_TRICKS!r} key specified in {tricks_file!r}."
)
if CONFIG_KEY_PYTHON_PATH in config:
add_to_sys_path(config[CONFIG_KEY_PYTHON_PATH])
dir_path = os.path.dirname(tricks_file)
if not dir_path:
dir_path = os.path.relpath(os.getcwd())
schedule_tricks(observer, tricks, dir_path, args.recursive)
observer.start()
observers.append(observer)
try:
while True:
time.sleep(1)
except WatchdogShutdown:
for o in observers:
o.unschedule_all()
o.stop()
for o in observers:
o.join()
@command(
[
argument(
"trick_paths",
nargs="*",
help="Dotted paths for all the tricks you want to generate.",
),
argument(
"--python-path",
default=".",
help=f"Paths separated by {os.pathsep!r} to add to the Python path.",
),
argument(
"--append-to-file",
default=None,
help="""
Appends the generated tricks YAML to a file.
If not specified, prints to standard output.""",
),
argument(
"-a",
"--append-only",
dest="append_only",
action="store_true",
help="""
If --append-to-file is not specified, produces output for
appending instead of a complete tricks YAML file.""",
),
],
cmd_aliases=["generate-tricks-yaml"],
)
def tricks_generate_yaml(args):
"""
Command to generate Yaml configuration for tricks named on the command line.
"""
import yaml
python_paths = path_split(args.python_path)
add_to_sys_path(python_paths)
output = StringIO()
for trick_path in args.trick_paths:
TrickClass = load_class(trick_path)
output.write(TrickClass.generate_yaml())
content = output.getvalue()
output.close()
header = yaml.dump({CONFIG_KEY_PYTHON_PATH: python_paths})
header += f"{CONFIG_KEY_TRICKS}:\n"
if args.append_to_file is None:
# Output to standard output.
if not args.append_only:
content = header + content
sys.stdout.write(content)
else:
if not os.path.exists(args.append_to_file):
content = header + content
with open(args.append_to_file, "a", encoding="utf-8") as file:
file.write(content)
@command(
[
argument(
"directories",
nargs="*",
default=".",
help="Directories to watch. (default: '.').",
),
argument(
"-p",
"--pattern",
"--patterns",
dest="patterns",
default="*",
help="Matches event paths with these patterns (separated by ;).",
),
argument(
"-i",
"--ignore-pattern",
"--ignore-patterns",
dest="ignore_patterns",
default="",
help="Ignores event paths with these patterns (separated by ;).",
),
argument(
"-D",
"--ignore-directories",
dest="ignore_directories",
action="store_true",
help="Ignores events for directories.",
),
argument(
"-R",
"--recursive",
dest="recursive",
action="store_true",
help="Monitors the directories recursively.",
),
argument(
"--interval",
"--timeout",
dest="timeout",
default=1.0,
type=float,
help="Use this as the polling interval/blocking timeout.",
),
argument(
"--trace", action="store_true", help="Dumps complete dispatching trace."
),
argument(
"--debug-force-polling", action="store_true", help="[debug] Forces polling."
),
argument(
"--debug-force-kqueue",
action="store_true",
help="[debug] Forces BSD kqueue(2).",
),
argument(
"--debug-force-winapi",
action="store_true",
help="[debug] Forces Windows API.",
),
argument(
"--debug-force-fsevents",
action="store_true",
help="[debug] Forces macOS FSEvents.",
),
argument(
"--debug-force-inotify",
action="store_true",
help="[debug] Forces Linux inotify(7).",
),
]
)
def log(args):
"""
Command to log file system events to the console.
"""
from libs.watchdog.tricks import LoggerTrick
from libs.watchdog.utils import echo
if args.trace:
class_module_logger = logging.getLogger(LoggerTrick.__module__)
echo.echo_class(LoggerTrick, write=lambda msg: class_module_logger.info(msg))
patterns, ignore_patterns = parse_patterns(args.patterns, args.ignore_patterns)
handler = LoggerTrick(
patterns=patterns,
ignore_patterns=ignore_patterns,
ignore_directories=args.ignore_directories,
)
Observer: BaseObserverSubclassCallable
if args.debug_force_polling:
from libs.watchdog.observers.polling import PollingObserver as Observer
elif args.debug_force_kqueue:
from libs.watchdog.observers.kqueue import KqueueObserver as Observer
elif (not TYPE_CHECKING and args.debug_force_winapi) or (TYPE_CHECKING and sys.platform.startswith("win")):
from libs.watchdog.observers.read_directory_changes import WindowsApiObserver as Observer
elif args.debug_force_inotify:
from libs.watchdog.observers.inotify import InotifyObserver as Observer
elif args.debug_force_fsevents:
from libs.watchdog.observers.fsevents import FSEventsObserver as Observer
else:
# Automatically picks the most appropriate observer for the platform
# on which it is running.
from libs.watchdog.observers import Observer
observer = Observer(timeout=args.timeout)
observe_with(observer, handler, args.directories, args.recursive)
@command(
[
argument("directories", nargs="*", default=".", help="Directories to watch."),
argument(
"-c",
"--command",
dest="command",
default=None,
help="""
Shell command executed in response to matching events.
These interpolation variables are available to your command string:
${watch_src_path} - event source path
${watch_dest_path} - event destination path (for moved events)
${watch_event_type} - event type
${watch_object} - 'file' or 'directory'
Note:
Please ensure you do not use double quotes (") to quote
your command string. That will force your shell to
interpolate before the command is processed by this
command.
Example:
--command='echo "${watch_src_path}"'
""",
),
argument(
"-p",
"--pattern",
"--patterns",
dest="patterns",
default="*",
help="Matches event paths with these patterns (separated by ;).",
),
argument(
"-i",
"--ignore-pattern",
"--ignore-patterns",
dest="ignore_patterns",
default="",
help="Ignores event paths with these patterns (separated by ;).",
),
argument(
"-D",
"--ignore-directories",
dest="ignore_directories",
default=False,
action="store_true",
help="Ignores events for directories.",
),
argument(
"-R",
"--recursive",
dest="recursive",
action="store_true",
help="Monitors the directories recursively.",
),
argument(
"--interval",
"--timeout",
dest="timeout",
default=1.0,
type=float,
help="Use this as the polling interval/blocking timeout.",
),
argument(
"-w",
"--wait",
dest="wait_for_process",
action="store_true",
help="Wait for process to finish to avoid multiple simultaneous instances.",
),
argument(
"-W",
"--drop",
dest="drop_during_process",
action="store_true",
help="Ignore events that occur while command is still being"
" executed to avoid multiple simultaneous instances.",
),
argument(
"--debug-force-polling", action="store_true", help="[debug] Forces polling."
),
]
)
def shell_command(args):
"""
Command to execute shell commands in response to file system events.
"""
from libs.watchdog.tricks import ShellCommandTrick
if not args.command:
args.command = None
Observer: BaseObserverSubclassCallable
if args.debug_force_polling:
from libs.watchdog.observers.polling import PollingObserver as Observer
else:
from libs.watchdog.observers import Observer
patterns, ignore_patterns = parse_patterns(args.patterns, args.ignore_patterns)
handler = ShellCommandTrick(
shell_command=args.command,
patterns=patterns,
ignore_patterns=ignore_patterns,
ignore_directories=args.ignore_directories,
wait_for_process=args.wait_for_process,
drop_during_process=args.drop_during_process,
)
observer = Observer(timeout=args.timeout)
observe_with(observer, handler, args.directories, args.recursive)
@command(
[
argument("command", help="Long-running command to run in a subprocess."),
argument(
"command_args",
metavar="arg",
nargs="*",
help="""
Command arguments.
Note: Use -- before the command arguments, otherwise watchmedo will
try to interpret them.
""",
),
argument(
"-d",
"--directory",
dest="directories",
metavar="DIRECTORY",
action="append",
help="Directory to watch. Use another -d or --directory option "
"for each directory.",
),
argument(
"-p",
"--pattern",
"--patterns",
dest="patterns",
default="*",
help="Matches event paths with these patterns (separated by ;).",
),
argument(
"-i",
"--ignore-pattern",
"--ignore-patterns",
dest="ignore_patterns",
default="",
help="Ignores event paths with these patterns (separated by ;).",
),
argument(
"-D",
"--ignore-directories",
dest="ignore_directories",
default=False,
action="store_true",
help="Ignores events for directories.",
),
argument(
"-R",
"--recursive",
dest="recursive",
action="store_true",
help="Monitors the directories recursively.",
),
argument(
"--interval",
"--timeout",
dest="timeout",
default=1.0,
type=float,
help="Use this as the polling interval/blocking timeout.",
),
argument(
"--signal",
dest="signal",
default="SIGINT",
help="Stop the subprocess with this signal (default SIGINT).",
),
argument(
"--debug-force-polling", action="store_true", help="[debug] Forces polling."
),
argument(
"--kill-after",
dest="kill_after",
default=10.0,
type=float,
help="When stopping, kill the subprocess after the specified timeout "
"in seconds (default 10.0).",
),
argument(
"--debounce-interval",
dest="debounce_interval",
default=0.0,
type=float,
help="After a file change, Wait until the specified interval (in "
"seconds) passes with no file changes, and only then restart.",
),
argument(
"--no-restart-on-command-exit",
dest="restart_on_command_exit",
default=True,
action="store_false",
help="Don't auto-restart the command after it exits.",
),
]
)
def auto_restart(args):
"""
Command to start a long-running subprocess and restart it on matched events.
"""
Observer: BaseObserverSubclassCallable
if args.debug_force_polling:
from libs.watchdog.observers.polling import PollingObserver as Observer
else:
from libs.watchdog.observers import Observer
import signal
from libs.watchdog.tricks import AutoRestartTrick
if not args.directories:
args.directories = ["."]
# Allow either signal name or number.
if args.signal.startswith("SIG"):
stop_signal = getattr(signal, args.signal)
else:
stop_signal = int(args.signal)
# Handle termination signals by raising a semantic exception which will
# allow us to gracefully unwind and stop the observer
termination_signals = {signal.SIGTERM, signal.SIGINT}
if hasattr(signal, "SIGHUP"):
termination_signals.add(signal.SIGHUP)
def handler_termination_signal(_signum, _frame):
# Neuter all signals so that we don't attempt a double shutdown
for signum in termination_signals:
signal.signal(signum, signal.SIG_IGN)
raise WatchdogShutdown
for signum in termination_signals:
signal.signal(signum, handler_termination_signal)
patterns, ignore_patterns = parse_patterns(args.patterns, args.ignore_patterns)
command = [args.command]
command.extend(args.command_args)
handler = AutoRestartTrick(
command=command,
patterns=patterns,
ignore_patterns=ignore_patterns,
ignore_directories=args.ignore_directories,
stop_signal=stop_signal,
kill_after=args.kill_after,
debounce_interval_seconds=args.debounce_interval,
restart_on_command_exit=args.restart_on_command_exit,
)
handler.start()
observer = Observer(timeout=args.timeout)
try:
observe_with(observer, handler, args.directories, args.recursive)
except WatchdogShutdown:
pass
finally:
handler.stop()
class LogLevelException(Exception):
pass
def _get_log_level_from_args(args):
verbosity = sum(args.verbosity or [])
if verbosity < -1:
raise LogLevelException("-q/--quiet may be specified only once.")
if verbosity > 2:
raise LogLevelException("-v/--verbose may be specified up to 2 times.")
return ["ERROR", "WARNING", "INFO", "DEBUG"][1 + verbosity]
def main():
"""Entry-point function."""
args = cli.parse_args()
if args.top_command is None:
cli.print_help()
return 1
try:
log_level = _get_log_level_from_args(args)
except LogLevelException as exc:
print(f"Error: {exc.args[0]}", file=sys.stderr)
command_parsers[args.top_command].print_help()
return 1
logging.getLogger("libs.watchdog").setLevel(log_level)
try:
args.func(args)
except KeyboardInterrupt:
return 130
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,114 +0,0 @@
# Python imports
import os
import threading
import time
from multiprocessing.connection import Client
from multiprocessing.connection import Listener
# Lib imports
# Application imports
from .singleton import Singleton
class IPCServer(Singleton):
""" Create a listener so that other {app_name} instances send requests back to existing instance. """
def __init__(self, ipc_address: str = '127.0.0.1', conn_type: str = "socket"):
self.is_ipc_alive = False
self._ipc_port = 4848
self._ipc_address = ipc_address
self._conn_type = conn_type
self._ipc_authkey = b'' + bytes(f'{app_name}-ipc', 'utf-8')
self._ipc_timeout = 15.0
if conn_type == "socket":
self._ipc_address = f'/tmp/{app_name}-ipc.sock'
elif conn_type == "full_network":
self._ipc_address = '0.0.0.0'
elif conn_type == "full_network_unsecured":
self._ipc_authkey = None
self._ipc_address = '0.0.0.0'
elif conn_type == "local_network_unsecured":
self._ipc_authkey = None
self._subscribe_to_events()
def _subscribe_to_events(self):
event_system.subscribe("post_file_to_ipc", self.send_ipc_message)
def create_ipc_listener(self) -> None:
if self._conn_type == "socket":
if os.path.exists(self._ipc_address) and settings_manager.is_dirty_start():
os.unlink(self._ipc_address)
listener = Listener(address=self._ipc_address, family="AF_UNIX", authkey=self._ipc_authkey)
elif "unsecured" not in self._conn_type:
listener = Listener((self._ipc_address, self._ipc_port), authkey=self._ipc_authkey)
else:
listener = Listener((self._ipc_address, self._ipc_port))
self.is_ipc_alive = True
self._run_ipc_loop(listener)
@daemon_threaded
def _run_ipc_loop(self, listener) -> None:
# NOTE: Not thread safe if using with Gtk. Need to import GLib and use idle_add
while True:
try:
conn = listener.accept()
start_time = time.perf_counter()
self._handle_ipc_message(conn, start_time)
except Exception as e:
...
listener.close()
def _handle_ipc_message(self, conn, start_time) -> None:
while True:
msg = conn.recv()
if settings_manager.is_debug():
print(msg)
if "FILE|" in msg:
file = msg.split("FILE|")[1].strip()
if file:
event_system.emit("handle_file_from_ipc", file)
if "DIR|" in msg:
file = msg.split("DIR|")[1].strip()
if file:
event_system.emit("handle_dir_from_ipc", file)
conn.close()
break
if msg in ['close connection', 'close server']:
conn.close()
break
# NOTE: Not perfect but insures we don't lock up the connection for too long.
end_time = time.perf_counter()
if (end_time - start_time) > self._ipc_timeout:
conn.close()
break
def send_ipc_message(self, message: str = "Empty Data...") -> None:
try:
if self._conn_type == "socket":
conn = Client(address=self._ipc_address, family="AF_UNIX", authkey=self._ipc_authkey)
elif "unsecured" not in self._conn_type:
conn = Client((self._ipc_address, self._ipc_port), authkey=self._ipc_authkey)
else:
conn = Client((self._ipc_address, self._ipc_port))
conn.send(message)
conn.close()
except ConnectionRefusedError as e:
print("Connection refused...")
except Exception as e:
print(repr(e))