def setUp(self): self._dir = TemporaryDirectory() self.dir = Path(self._dir.name) self.inotify = Inotify() self.watch = self.inotify.add_watch( self.dir, Mask.ACCESS | Mask.MODIFY | Mask.ATTRIB | Mask.CLOSE_WRITE | Mask.CLOSE_NOWRITE | Mask.OPEN | Mask.MOVED_FROM | Mask.MOVED_TO | Mask.CREATE | Mask.DELETE | Mask.DELETE_SELF | Mask.MOVE_SELF)
def __init__(self, path: Path): self.path = path # Store the inotify mask which is used throughout the watcher self.mask = Mask.MOVED_FROM | \ Mask.MOVED_TO | \ Mask.CREATE | \ Mask.DELETE_SELF | \ Mask.IGNORED | \ Mask.CLOSE_WRITE # Setup an inotify watching instance self.notifier = Inotify() # Queue of the various notifications to be sent at timestamps self.alarm_queue = [] self.trigger = None
async def monitor(self): # pragma: no cover """ This function returns a coroutine to be set on the main loop. This function will async block waiting for changes to the config. On change event, reload the configuration. """ with Inotify() as inotify: inotify.add_watch(self.fname, Mask.MODIFY) async for _ in inotify: await self.aread()
async def watch_recursive(path: Path, mask: Mask) -> AsyncGenerator[Event, None]: with Inotify() as inotify: for directory in get_directories_recursive(path): print(f'INIT: watching {directory}') inotify.add_watch(directory, mask | Mask.MOVED_FROM | Mask.MOVED_TO | Mask.CREATE | Mask.DELETE_SELF | Mask.IGNORED) # Things that can throw this off: # # * Moving a watched directory out of the watch tree (will still # generate events even when outside of directory tree) # # * Doing two changes on a directory or something before the program # has a time to handle it (this will also throw off a lot of inotify # code, though) # # * Moving a watched directory within a watched directory will get the # wrong path. This needs to use the cookie system to link events # together and complete the move properly, which can still make some # events get the wrong path if you get file events during the move or # something silly like that, since MOVED_FROM and MOVED_TO aren't # guaranteed to be contiguous. That exercise is left up to the # reader. # # * Trying to watch a path that doesn't exist won't automatically # create it or anything of the sort. # # * Deleting and recreating or moving the watched directory won't do # anything special, but it probably should. async for event in inotify: # Add subdirectories to watch if a new directory is added. We do # this recursively here before processing events to make sure we # have complete coverage of existing and newly-created directories # by watching before recursing and adding, since we know # get_directories_recursive is depth-first and yields every # directory before iterating their children, we know we won't miss # anything. if Mask.CREATE in event.mask and event.path is not None and event.path.is_dir(): for directory in get_directories_recursive(event.path): print(f'EVENT: watching {directory}') inotify.add_watch(directory, mask | Mask.MOVED_FROM | Mask.MOVED_TO | Mask.CREATE | Mask.DELETE_SELF | Mask.IGNORED) # If there is at least some overlap, assume the user wants this event. if event.mask & mask: yield event else: # Note that these events are needed for cleanup purposes. # We'll always get IGNORED events so the watch can be removed # from the inotify. We don't need to do anything with the # events, but they do need to be generated for cleanup. # We don't need to pass IGNORED events up, because the end-user # doesn't have the inotify instance anyway, and IGNORED is just # used for management purposes. print(f'UNYIELDED EVENT: {event}')
async def watch_directories(directories, handler): with Inotify() as inotify: for dirs in directories: inotify.add_watch(dirs, Mask.CREATE) for files in os.listdir(dirs): full_path = os.path.join(dirs, files) if os.path.isdir(full_path): await handler.handle_create_event(full_path) async for event in inotify: await handler.handle_create_event(str(event.path))
async def cfg_mods_worker(self, reload_one=True): """ Reloading configs on change. Reload only appropriate config by default. watcher: watcher for cfg. """ config_extension = '.py' while True: with Inotify() as inotify: inotify.add_watch( f'{Misc.i3path()}/cfg/', Mask.MODIFY) async for event in inotify: changed_mod = str(event.name).removesuffix(config_extension) if changed_mod in self.mods: binpath = f'{Misc.i3path()}/bin/' subprocess.run( [f'{binpath}/create_config.py'], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, cwd=binpath, check=False ) if reload_one: self.mods[changed_mod].bindings['reload']() else: for mod in self.mods: self.mods[mod].bindings['reload']()
async def watch_file_changes(self): with Inotify() as inotify: for directory, _, _ in os.walk(os.getcwd()): # TODO How to best ignore things like .git, .pytest_cache etc? path = pathlib.Path(directory) if any( p.startswith(".") or p == "__pycache__" for p in path.parts): continue inotify.add_watch( directory, Mask.MODIFY | Mask.CREATE | Mask.DELETE | Mask.MOVE | Mask.ONLYDIR | Mask.MASK_CREATE, ) async for event in inotify: if event.name.suffix == ".py": self.start_test_run()
async def run_daemon() -> None: log.debug("Running daemon on D-Bus system bus.") bus = await MessageBus(bus_type=BusType.SYSTEM).connect() interface = ChargeDaemon() log.debug(f"Exporting interface '{DBUS_NAME}' to path '{DBUS_PATH}'") bus.export(DBUS_PATH, interface) if ( not (reply := await bus.request_name(DBUS_NAME)) == RequestNameReply.PRIMARY_OWNER ): log.critical( f"Unable to acquire primary ownership of bus name '{DBUS_NAME}'. In state: {reply.name}" ) raise SystemExit(1) log.debug("Daemon running...") inotify = Inotify() inotify.add_watch(path.dirname(interface.controller.bat_path), Mask.CLOSE_WRITE) inotify.add_watch(CONFIG_DIR, Mask.CLOSE_WRITE) tasks = asyncio.gather( bus.wait_for_disconnect(), iterate_threshold_events(inotify, interface) ) def sig_handler(signum, frame) -> None: log.debug(f"Received signal: {signal.Signals(signum).name}") tasks.cancel() bus.disconnect() if interface.notifier: interface.notifier.close() inotify.close() raise SystemExit(0)
def watch_photos(self): watching_libraries = {} with Inotify() as inotify: @sync_to_async def get_libraries(): return { l.path: l.library_id for l in LibraryPath.objects.filter(type='St', backend_type='Lo') } @sync_to_async def record_photo_async(photo_path, library_id, event_mask): record_photo(photo_path, library_id, event_mask) async def check_libraries(): while True: await asyncio.sleep(1) current_libraries = await get_libraries() for path, id in current_libraries.items(): if path not in watching_libraries: print('Watching new path:', path) watch = inotify.add_watch( path, Mask.MODIFY | Mask.CREATE | Mask.DELETE | Mask.CLOSE | Mask.MOVE) watching_libraries[path] = (id, watch) for path, (id, watch) in watching_libraries.items(): if path not in current_libraries: print('Removing old path:', path) inotify.rm_watch(watch) await asyncio.sleep(4) async def handle_inotify_events(): async for event in inotify: if event.mask in [ Mask.CLOSE_WRITE, Mask.MOVED_TO, Mask.DELETE, Mask.MOVED_FROM ]: photo_path = event.path library_id = None for potential_library_path, ( potential_library_id, _) in watching_libraries.items(): if str(photo_path).startswith( potential_library_path): library_id = potential_library_id if event.mask in [Mask.DELETE, Mask.MOVED_FROM]: print( f'Removing photo "{photo_path}" from library "{library_id}"' ) await record_photo_async( photo_path, library_id, str(event.mask).split('.')[1]) else: if imghdr.what(photo_path) or not subprocess.run( ['dcraw', '-i', photo_path]).returncode: print( f'Adding photo "{photo_path}" to library "{library_id}"' ) await record_photo_async( photo_path, library_id, str(event.mask).split('.')[1]) loop = asyncio.get_event_loop() loop.create_task(check_libraries()) loop.create_task(handle_inotify_events()) try: loop.run_forever() except KeyboardInterrupt: print('Shutting down') finally: loop.run_until_complete(loop.shutdown_asyncgens()) loop.close()
class IcalAlarmWatcher: def __init__(self, path: Path): self.path = path # Store the inotify mask which is used throughout the watcher self.mask = Mask.MOVED_FROM | \ Mask.MOVED_TO | \ Mask.CREATE | \ Mask.DELETE_SELF | \ Mask.IGNORED | \ Mask.CLOSE_WRITE # Setup an inotify watching instance self.notifier = Inotify() # Queue of the various notifications to be sent at timestamps self.alarm_queue = [] self.trigger = None async def init_alarms(self, path: Path): """Add the alarms for all of the ics files under a directory (recursively) to the alarm queue""" for ics in path.glob('**/*.ics'): await self.insert_alarm(ics) async def add_listeners(self, path: Path): """Add the required listeners for a directory and its children""" self.notifier.add_watch(path, self.mask) for child in self.get_subdirs(path): self.notifier.add_watch(child, self.mask) async def listen(self): """Listen for changes in files, making sure to track newly created files and update the ICS alarms as changes come in""" # Add listeners for the root directory and all of its children await self.init_alarms(self.path) await self.add_listeners(self.path) # Prime the first alarm to trigger a notification self.prime() # Loop through the inotify listeners to check for changes to files async for event in self.notifier: # Listen for new dirs, and add listeners for them if Mask.CREATE in event.mask and event.path.is_dir(): await self.init_alarms(event.path) await self.add_listeners(event.path) self.prime() # A file changed, so regenerate the alarm queue and trigger # the new 'next alarm' elif not event.path.is_dir(): await self.insert_alarm(event.path) self.prime() async def insert_alarm(self, path: Path): """Insert the alarms given by the file at the specified path into the alarm queue as a Notification object""" # Remove old alarms related to this file from the queue self.alarm_queue = list( filter(lambda x: x[-1] != path, self.alarm_queue)) # Read content from the file try: async with aiofiles.open(path, mode='r') as f: content = await f.read() except FileNotFoundError: return # Parse the content into a calendar try: c = Calendar(content) except NotImplementedError: # TODO: Make sure this handles multiple calendars in a single file return # Loop trough the alarms in the calendar for event in c.events: for alarm in event.alarms: title = event.name details = event.description if not title: title = "iCalendar alarm" if not details: details = "Event happening soon" # If the alarm trigger is a timedelta prior to start, # convert it to an absolute time if isinstance(alarm.trigger, timedelta): alarm_time = event.begin + alarm.trigger else: alarm_time = alarm.trigger # Append this alarm to the queue alarm_time = datetime.fromtimestamp(alarm_time.timestamp) notification = Notification(title, details) heapq.heappush(self.alarm_queue, (alarm_time, notification, path)) def prime(self): """Clear any alarm triggers already in memory, and use the most recent alarm in the alarm_queue to setup a new notification event""" # Cancel the current alarm so the new one can be triggered on its own if self.trigger is not None: self.trigger.cancel() # Go through the alarm queue and remove notifications that should # have been triggered some time ago while self.alarm_queue and self.alarm_queue[0][0] < datetime.now(): heapq.heappop(self.alarm_queue) if not self.alarm_queue: return # Obtain the data of the next alarm to be triggered (timestamp, notification, path) = self.alarm_queue[-1] # Small function that triggers the notification for an alarm, # and then clears it from the queue def _notify_and_pop(): notification.notify() heapq.heappop(self.alarm_queue) self.trigger = Trigger(timestamp, _notify_and_pop) self.trigger() @classmethod def get_subdirs(cls, path: Path) -> Generator[Path, None, None]: """Recursively list all directories under path""" for child in path.iterdir(): if child.is_dir(): yield child yield from cls.get_subdirs(child)
class TestSyncInotify(unittest.TestCase): def watch_events(self): '''Watch events until an IGNORED is received for the main watch, then return the events.''' events = [] with self.inotify as inotify: for event in inotify: events.append(event) if Mask.IGNORED in event and event.watch is self.watch: return events def gather_events(self, function): '''Run the function and then watch events until you can return the result.''' try: function() finally: self.inotify.rm_watch(self.watch) return self.watch_events() def setUp(self): self._dir = TemporaryDirectory() self.dir = Path(self._dir.name) self.inotify = Inotify() self.watch = self.inotify.add_watch( self.dir, Mask.ACCESS | Mask.MODIFY | Mask.ATTRIB | Mask.CLOSE_WRITE | Mask.CLOSE_NOWRITE | Mask.OPEN | Mask.MOVED_FROM | Mask.MOVED_TO | Mask.CREATE | Mask.DELETE | Mask.DELETE_SELF | Mask.MOVE_SELF) def tearDown(self): self._dir.cleanup() def test_diriterated(self): def test(): list(self.dir.iterdir()) events = self.gather_events(test) self.assertTrue(all(event.watch is self.watch for event in events)) self.assertTrue( any(Mask.ISDIR | Mask.OPEN in event and event.path == self.dir for event in events)) self.assertTrue( any(Mask.ISDIR | Mask.ACCESS in event and event.path == self.dir for event in events)) self.assertTrue( any(Mask.ISDIR | Mask.CLOSE_NOWRITE in event and event.path == self.dir for event in events)) self.assertTrue( any(Mask.IGNORED in event and event.path == self.dir for event in events)) def test_foo_opened_and_closed(self): def test(): with open(self.dir / 'foo', 'w') as file: pass with open(self.dir / 'foo', 'r') as file: pass events = self.gather_events(test) self.assertTrue(all(event.watch is self.watch for event in events)) self.assertTrue( any(Mask.CREATE in event and event.path == self.dir / 'foo' for event in events)) self.assertTrue( any(Mask.OPEN in event and event.path == self.dir / 'foo' for event in events)) self.assertTrue( any(Mask.CLOSE_WRITE in event and event.path == self.dir / 'foo' for event in events)) self.assertTrue( any(Mask.CLOSE_NOWRITE in event and event.path == self.dir / 'foo' for event in events)) def test_foo_deleted(self): def test(): with open(self.dir / 'foo', 'w') as file: pass (self.dir / 'foo').unlink() events = self.gather_events(test) self.assertTrue(all(event.watch is self.watch for event in events)) self.assertTrue( any(Mask.DELETE in event and event.path == self.dir / 'foo' for event in events)) def test_foo_write(self): def test(): with open(self.dir / 'foo', 'w') as file: file.write('test') events = self.gather_events(test) self.assertTrue(all(event.watch is self.watch for event in events)) self.assertTrue( any(Mask.CREATE in event and event.path == self.dir / 'foo' for event in events)) self.assertTrue( any(Mask.OPEN in event and event.path == self.dir / 'foo' for event in events)) self.assertTrue( any(Mask.MODIFY in event and event.path == self.dir / 'foo' for event in events)) self.assertTrue( any(Mask.CLOSE_WRITE in event and event.path == self.dir / 'foo' for event in events)) def test_foo_moved(self): def test(): with open(self.dir / 'foo', 'w') as file: pass (self.dir / 'foo').rename(self.dir / 'bar') events = self.gather_events(test) self.assertTrue(all(event.watch is self.watch for event in events)) self.assertTrue( any(Mask.MOVED_FROM in event and event.path == self.dir / 'foo' for event in events)) self.assertTrue( any(Mask.MOVED_TO in event and event.path == self.dir / 'bar' for event in events)) self.assertEqual( next(event.cookie for event in events if Mask.MOVED_FROM in event), next(event.cookie for event in events if Mask.MOVED_TO in event), ) def test_foo_attrib(self): def test(): with open(self.dir / 'foo', 'w') as file: pass (self.dir / 'foo').chmod(0o777) events = self.gather_events(test) self.assertTrue(all(event.watch is self.watch for event in events)) self.assertTrue( any(Mask.ATTRIB in event and event.path == self.dir / 'foo' for event in events)) def test_onlydir_error(self): with open(self.dir / 'foo', 'w'): pass # Will not raise error self.inotify.add_watch(self.dir / 'foo', Mask.ATTRIB) with self.assertRaises(InotifyError): self.inotify.add_watch(self.dir / 'foo', Mask.ATTRIB | Mask.ONLYDIR) def test_nonexist_error(self): with self.assertRaises(InotifyError): self.inotify.add_watch(self.dir / 'foo', Mask.ATTRIB | Mask.ONLYDIR) with self.assertRaises(InotifyError): self.inotify.add_watch(self.dir / 'foo', Mask.ATTRIB) def test_move_self(self): with open(self.dir / 'foo', 'w'): pass watch = self.inotify.add_watch(self.dir / 'foo', Mask.MOVE_SELF) def test(): (self.dir / 'foo').rename(self.dir / 'bar') events = self.gather_events(test) self.assertTrue( any(Mask.MOVE_SELF in event and event.path == self.dir / 'foo' and event.watch is watch for event in events)) def test_delete_self(self): with open(self.dir / 'foo', 'w'): pass watch = self.inotify.add_watch(self.dir / 'foo', Mask.DELETE_SELF) def test(): (self.dir / 'foo').unlink() events = self.gather_events(test) self.assertTrue( any(Mask.DELETE_SELF in event and event.path == self.dir / 'foo' and event.watch is watch for event in events)) self.assertTrue( any(Mask.IGNORED in event and event.path == self.dir / 'foo' and event.watch is watch for event in events)) self.assertTrue( any(Mask.IGNORED in event and event.path == self.dir for event in events)) def test_oneshot(self): with open(self.dir / 'foo', 'w'): pass watch = self.inotify.add_watch(self.dir / 'foo', Mask.CREATE | Mask.OPEN | Mask.ONESHOT) def test(): with open(self.dir / 'foo', 'r'): pass (self.dir / 'foo').unlink() events = self.gather_events(test) # We check for name is None because only the first event will have a watch value self.assertTrue( any(Mask.OPEN in event and event.name is None and event.path == self.dir / 'foo' and event.watch is watch for event in events)) # The oneshot has already expired, so this should not exist self.assertFalse( any(Mask.DELETE in event and event.name is None for event in events))
def watch_photos(self): """Management command to watch photo directory and create photo records in database.""" watching_libraries = {} with Inotify() as inotify: @sync_to_async def get_libraries(): return {l.path: l.library_id for l in LibraryPath.objects.filter(type='St', backend_type='Lo')} @sync_to_async def record_photo_async(photo_path, library_id, event_mask): record_photo(photo_path, library_id, event_mask) @sync_to_async def move_or_rename_photo_async(photo_old_path, photo_new_path, library_id): move_or_rename_photo(photo_old_path, photo_new_path, library_id) @sync_to_async def delete_child_dir_all_photos_async(photo_path, library_id): delete_child_dir_all_photos(photo_path, library_id) def get_directories_recursive(path: Path) -> Generator[Path, None, None]: """ Recursively list all directories under path, including path itself, if it's a directory. The path itself is always yielded before its children are iterated, so you can pre-process a path (by watching it with inotify) before you get the directory listing. Passing a non-directory won't raise an error or anything, it'll just yield nothing. """ if path.is_dir(): yield path for child in path.iterdir(): yield from get_directories_recursive(child) async def check_libraries(): while True: await asyncio.sleep(1) current_libraries = await get_libraries() for path, id in current_libraries.items(): if path not in watching_libraries: for directory in get_directories_recursive(Path(path)): logger.info(f'Watching new path: {directory}') watch = inotify.add_watch(directory, Mask.MODIFY | Mask.CREATE | Mask.DELETE | Mask.CLOSE | Mask.MOVE) watching_libraries[path] = (id, watch) for path, (id, watch) in watching_libraries.items(): if path not in current_libraries: logger.info(f'Removing old path: {path}') inotify.rm_watch(watch) await asyncio.sleep(4) async def handle_inotify_events(): async for event in inotify: if 'moved_from_attr_dict' in locals() and moved_from_attr_dict: for potential_library_path, (potential_library_id, _) in watching_libraries.items(): if str(event.path).startswith(potential_library_path): library_id = potential_library_id photo_moved_from_path = moved_from_attr_dict.get('moved_from_path') photo_moved_from_cookie = moved_from_attr_dict.get('moved_from_cookie') moved_from_attr_dict = {} if event.mask.name == 'MOVED_TO' and photo_moved_from_cookie == event.cookie: logger.info(f'Moving or renaming the photo "{str(event.path)}" from library "{library_id}"') await move_or_rename_photo_async(photo_moved_from_path, event.path, library_id) else: logger.info(f'Removing photo "{str(photo_moved_from_path)}" from library "{library_id}"') await record_photo_async(photo_moved_from_path, library_id, 'MOVED_FROM') elif Mask.CREATE in event.mask and event.path is not None and event.path.is_dir(): current_libraries = await get_libraries() for path, id in current_libraries.items(): for directory in get_directories_recursive(event.path): logger.info(f'Watching newly created child directory: {directory}') watch = inotify.add_watch(directory, Mask.MODIFY | Mask.CREATE | Mask.DELETE | Mask.CLOSE | Mask.MOVE) watching_libraries[path] = (id, watch) elif event.mask in [Mask.CLOSE_WRITE, Mask.MOVED_TO, Mask.DELETE, Mask.MOVED_FROM] or event.mask.value == 1073741888: photo_path = event.path library_id = None for potential_library_path, (potential_library_id, _) in watching_libraries.items(): if str(photo_path).startswith(potential_library_path): library_id = potential_library_id if event.mask in [Mask.DELETE, Mask.MOVED_FROM]: if event.mask.name == 'MOVED_FROM': moved_from_attr_dict = { 'moved_from_path': event.path, 'moved_from_cookie': event.cookie} else: logger.info(f'Removing photo "{photo_path}" from library "{library_id}"') await record_photo_async(photo_path, library_id, event.mask.name) elif event.mask.value == 1073741888: logger.info(f'Delete child directory with its all photos "{photo_path}" to library "{library_id}"') await delete_child_dir_all_photos_async(photo_path, library_id) else: logger.info(f'Adding photo "{photo_path}" to library "{library_id}"') await record_photo_async(photo_path, library_id, event.mask.name) loop = asyncio.get_event_loop() loop.create_task(check_libraries()) loop.create_task(handle_inotify_events()) try: loop.run_forever() except KeyboardInterrupt: logger.info('Shutting down') finally: loop.run_until_complete(loop.shutdown_asyncgens()) loop.close()