1from __future__ import annotations 2 3import contextlib 4import ctypes 5import ctypes.util 6import errno 7import os 8import struct 9import threading 10from ctypes import c_char_p, c_int, c_uint32 11from functools import reduce 12from typing import TYPE_CHECKING 13 14from watchdog.utils import UnsupportedLibcError 15 16if TYPE_CHECKING: 17 from collections.abc import Generator 18 19libc = ctypes.CDLL(None) 20 21if not hasattr(libc, "inotify_init") or not hasattr(libc, "inotify_add_watch") or not hasattr(libc, "inotify_rm_watch"): 22 error = f"Unsupported libc version found: {libc._name}" # noqa:SLF001 23 raise UnsupportedLibcError(error) 24 25inotify_add_watch = ctypes.CFUNCTYPE(c_int, c_int, c_char_p, c_uint32, use_errno=True)(("inotify_add_watch", libc)) 26 27inotify_rm_watch = ctypes.CFUNCTYPE(c_int, c_int, c_uint32, use_errno=True)(("inotify_rm_watch", libc)) 28 29inotify_init = ctypes.CFUNCTYPE(c_int, use_errno=True)(("inotify_init", libc)) 30 31 32class InotifyConstants: 33 # User-space events 34 IN_ACCESS = 0x00000001 # File was accessed. 35 IN_MODIFY = 0x00000002 # File was modified. 36 IN_ATTRIB = 0x00000004 # Meta-data changed. 37 IN_CLOSE_WRITE = 0x00000008 # Writable file was closed. 38 IN_CLOSE_NOWRITE = 0x00000010 # Unwritable file closed. 39 IN_OPEN = 0x00000020 # File was opened. 40 IN_MOVED_FROM = 0x00000040 # File was moved from X. 41 IN_MOVED_TO = 0x00000080 # File was moved to Y. 42 IN_CREATE = 0x00000100 # Subfile was created. 43 IN_DELETE = 0x00000200 # Subfile was deleted. 44 IN_DELETE_SELF = 0x00000400 # Self was deleted. 45 IN_MOVE_SELF = 0x00000800 # Self was moved. 46 47 # Helper user-space events. 48 IN_MOVE = IN_MOVED_FROM | IN_MOVED_TO # Moves. 49 50 # Events sent by the kernel to a watch. 51 IN_UNMOUNT = 0x00002000 # Backing file system was unmounted. 52 IN_Q_OVERFLOW = 0x00004000 # Event queued overflowed. 53 IN_IGNORED = 0x00008000 # File was ignored. 54 55 # Special flags. 56 IN_ONLYDIR = 0x01000000 # Only watch the path if it's a directory. 57 IN_DONT_FOLLOW = 0x02000000 # Do not follow a symbolic link. 58 IN_EXCL_UNLINK = 0x04000000 # Exclude events on unlinked objects 59 IN_MASK_ADD = 0x20000000 # Add to the mask of an existing watch. 60 IN_ISDIR = 0x40000000 # Event occurred against directory. 61 IN_ONESHOT = 0x80000000 # Only send event once. 62 63 # All user-space events. 64 IN_ALL_EVENTS = reduce( 65 lambda x, y: x | y, 66 [ 67 IN_ACCESS, 68 IN_MODIFY, 69 IN_ATTRIB, 70 IN_CLOSE_WRITE, 71 IN_CLOSE_NOWRITE, 72 IN_OPEN, 73 IN_MOVED_FROM, 74 IN_MOVED_TO, 75 IN_DELETE, 76 IN_CREATE, 77 IN_DELETE_SELF, 78 IN_MOVE_SELF, 79 ], 80 ) 81 82 # Flags for ``inotify_init1`` 83 IN_CLOEXEC = 0x02000000 84 IN_NONBLOCK = 0x00004000 85 86 87# Watchdog's API cares only about these events. 88WATCHDOG_ALL_EVENTS = reduce( 89 lambda x, y: x | y, 90 [ 91 InotifyConstants.IN_MODIFY, 92 InotifyConstants.IN_ATTRIB, 93 InotifyConstants.IN_MOVED_FROM, 94 InotifyConstants.IN_MOVED_TO, 95 InotifyConstants.IN_CREATE, 96 InotifyConstants.IN_DELETE, 97 InotifyConstants.IN_DELETE_SELF, 98 InotifyConstants.IN_DONT_FOLLOW, 99 InotifyConstants.IN_CLOSE_WRITE, 100 InotifyConstants.IN_CLOSE_NOWRITE, 101 InotifyConstants.IN_OPEN, 102 ], 103) 104 105 106class InotifyEventStruct(ctypes.Structure): 107 """Structure representation of the inotify_event structure 108 (used in buffer size calculations):: 109 110 struct inotify_event { 111 __s32 wd; /* watch descriptor */ 112 __u32 mask; /* watch mask */ 113 __u32 cookie; /* cookie to synchronize two events */ 114 __u32 len; /* length (including nulls) of name */ 115 char name[0]; /* stub for possible name */ 116 }; 117 """ 118 119 _fields_ = ( 120 ("wd", c_int), 121 ("mask", c_uint32), 122 ("cookie", c_uint32), 123 ("len", c_uint32), 124 ("name", c_char_p), 125 ) 126 127 128EVENT_SIZE = ctypes.sizeof(InotifyEventStruct) 129DEFAULT_NUM_EVENTS = 2048 130DEFAULT_EVENT_BUFFER_SIZE = DEFAULT_NUM_EVENTS * (EVENT_SIZE + 16) 131 132 133class Inotify: 134 """Linux inotify(7) API wrapper class. 135 136 :param path: 137 The directory path for which we want an inotify object. 138 :type path: 139 :class:`bytes` 140 :param recursive: 141 ``True`` if subdirectories should be monitored; ``False`` otherwise. 142 """ 143 144 def __init__(self, path: bytes, *, recursive: bool = False, event_mask: int | None = None) -> None: 145 # The file descriptor associated with the inotify instance. 146 inotify_fd = inotify_init() 147 if inotify_fd == -1: 148 Inotify._raise_error() 149 self._inotify_fd = inotify_fd 150 self._lock = threading.Lock() 151 152 # Stores the watch descriptor for a given path. 153 self._wd_for_path: dict[bytes, int] = {} 154 self._path_for_wd: dict[int, bytes] = {} 155 156 self._path = path 157 # Default to all events 158 if event_mask is None: 159 event_mask = WATCHDOG_ALL_EVENTS 160 self._event_mask = event_mask 161 self._is_recursive = recursive 162 if os.path.isdir(path): 163 self._add_dir_watch(path, event_mask, recursive=recursive) 164 else: 165 self._add_watch(path, event_mask) 166 self._moved_from_events: dict[int, InotifyEvent] = {} 167 168 @property 169 def event_mask(self) -> int: 170 """The event mask for this inotify instance.""" 171 return self._event_mask 172 173 @property 174 def path(self) -> bytes: 175 """The path associated with the inotify instance.""" 176 return self._path 177 178 @property 179 def is_recursive(self) -> bool: 180 """Whether we are watching directories recursively.""" 181 return self._is_recursive 182 183 @property 184 def fd(self) -> int: 185 """The file descriptor associated with the inotify instance.""" 186 return self._inotify_fd 187 188 def clear_move_records(self) -> None: 189 """Clear cached records of MOVED_FROM events""" 190 self._moved_from_events = {} 191 192 def source_for_move(self, destination_event: InotifyEvent) -> bytes | None: 193 """The source path corresponding to the given MOVED_TO event. 194 195 If the source path is outside the monitored directories, None 196 is returned instead. 197 """ 198 if destination_event.cookie in self._moved_from_events: 199 return self._moved_from_events[destination_event.cookie].src_path 200 201 return None 202 203 def remember_move_from_event(self, event: InotifyEvent) -> None: 204 """Save this event as the source event for future MOVED_TO events to 205 reference. 206 """ 207 self._moved_from_events[event.cookie] = event 208 209 def add_watch(self, path: bytes) -> None: 210 """Adds a watch for the given path. 211 212 :param path: 213 Path to begin monitoring. 214 """ 215 with self._lock: 216 self._add_watch(path, self._event_mask) 217 218 def remove_watch(self, path: bytes) -> None: 219 """Removes a watch for the given path. 220 221 :param path: 222 Path string for which the watch will be removed. 223 """ 224 with self._lock: 225 wd = self._wd_for_path.pop(path) 226 del self._path_for_wd[wd] 227 if inotify_rm_watch(self._inotify_fd, wd) == -1: 228 Inotify._raise_error() 229 230 def close(self) -> None: 231 """Closes the inotify instance and removes all associated watches.""" 232 with self._lock: 233 if self._path in self._wd_for_path: 234 wd = self._wd_for_path[self._path] 235 inotify_rm_watch(self._inotify_fd, wd) 236 237 # descriptor may be invalid because file was deleted 238 with contextlib.suppress(OSError): 239 os.close(self._inotify_fd) 240 241 def read_events(self, *, event_buffer_size: int = DEFAULT_EVENT_BUFFER_SIZE) -> list[InotifyEvent]: 242 """Reads events from inotify and yields them.""" 243 # HACK: We need to traverse the directory path 244 # recursively and simulate events for newly 245 # created subdirectories/files. This will handle 246 # mkdir -p foobar/blah/bar; touch foobar/afile 247 248 def _recursive_simulate(src_path: bytes) -> list[InotifyEvent]: 249 events = [] 250 for root, dirnames, filenames in os.walk(src_path): 251 for dirname in dirnames: 252 with contextlib.suppress(OSError): 253 full_path = os.path.join(root, dirname) 254 wd_dir = self._add_watch(full_path, self._event_mask) 255 e = InotifyEvent( 256 wd_dir, 257 InotifyConstants.IN_CREATE | InotifyConstants.IN_ISDIR, 258 0, 259 dirname, 260 full_path, 261 ) 262 events.append(e) 263 for filename in filenames: 264 full_path = os.path.join(root, filename) 265 wd_parent_dir = self._wd_for_path[os.path.dirname(full_path)] 266 e = InotifyEvent( 267 wd_parent_dir, 268 InotifyConstants.IN_CREATE, 269 0, 270 filename, 271 full_path, 272 ) 273 events.append(e) 274 return events 275 276 event_buffer = None 277 while True: 278 try: 279 event_buffer = os.read(self._inotify_fd, event_buffer_size) 280 except OSError as e: 281 if e.errno == errno.EINTR: 282 continue 283 284 if e.errno == errno.EBADF: 285 return [] 286 287 raise 288 break 289 290 with self._lock: 291 event_list = [] 292 for wd, mask, cookie, name in Inotify._parse_event_buffer(event_buffer): 293 if wd == -1: 294 continue 295 wd_path = self._path_for_wd[wd] 296 src_path = os.path.join(wd_path, name) if name else wd_path # avoid trailing slash 297 inotify_event = InotifyEvent(wd, mask, cookie, name, src_path) 298 299 if inotify_event.is_moved_from: 300 self.remember_move_from_event(inotify_event) 301 elif inotify_event.is_moved_to: 302 move_src_path = self.source_for_move(inotify_event) 303 if move_src_path in self._wd_for_path: 304 moved_wd = self._wd_for_path[move_src_path] 305 del self._wd_for_path[move_src_path] 306 self._wd_for_path[inotify_event.src_path] = moved_wd 307 self._path_for_wd[moved_wd] = inotify_event.src_path 308 if self.is_recursive: 309 for _path in self._wd_for_path.copy(): 310 if _path.startswith(move_src_path + os.path.sep.encode()): 311 moved_wd = self._wd_for_path.pop(_path) 312 _move_to_path = _path.replace(move_src_path, inotify_event.src_path) 313 self._wd_for_path[_move_to_path] = moved_wd 314 self._path_for_wd[moved_wd] = _move_to_path 315 src_path = os.path.join(wd_path, name) 316 inotify_event = InotifyEvent(wd, mask, cookie, name, src_path) 317 318 if inotify_event.is_ignored: 319 # Clean up book-keeping for deleted watches. 320 path = self._path_for_wd.pop(wd) 321 if self._wd_for_path[path] == wd: 322 del self._wd_for_path[path] 323 324 event_list.append(inotify_event) 325 326 if self.is_recursive and inotify_event.is_directory and inotify_event.is_create: 327 # TODO: When a directory from another part of the 328 # filesystem is moved into a watched directory, this 329 # will not generate events for the directory tree. 330 # We need to coalesce IN_MOVED_TO events and those 331 # IN_MOVED_TO events which don't pair up with 332 # IN_MOVED_FROM events should be marked IN_CREATE 333 # instead relative to this directory. 334 try: 335 self._add_watch(src_path, self._event_mask) 336 except OSError: 337 continue 338 339 event_list.extend(_recursive_simulate(src_path)) 340 341 return event_list 342 343 # Non-synchronized methods. 344 def _add_dir_watch(self, path: bytes, mask: int, *, recursive: bool) -> None: 345 """Adds a watch (optionally recursively) for the given directory path 346 to monitor events specified by the mask. 347 348 :param path: 349 Path to monitor 350 :param recursive: 351 ``True`` to monitor recursively. 352 :param mask: 353 Event bit mask. 354 """ 355 if not os.path.isdir(path): 356 raise OSError(errno.ENOTDIR, os.strerror(errno.ENOTDIR), path) 357 self._add_watch(path, mask) 358 if recursive: 359 for root, dirnames, _ in os.walk(path): 360 for dirname in dirnames: 361 full_path = os.path.join(root, dirname) 362 if os.path.islink(full_path): 363 continue 364 self._add_watch(full_path, mask) 365 366 def _add_watch(self, path: bytes, mask: int) -> int: 367 """Adds a watch for the given path to monitor events specified by the 368 mask. 369 370 :param path: 371 Path to monitor 372 :param mask: 373 Event bit mask. 374 """ 375 wd = inotify_add_watch(self._inotify_fd, path, mask) 376 if wd == -1: 377 Inotify._raise_error() 378 self._wd_for_path[path] = wd 379 self._path_for_wd[wd] = path 380 return wd 381 382 @staticmethod 383 def _raise_error() -> None: 384 """Raises errors for inotify failures.""" 385 err = ctypes.get_errno() 386 387 if err == errno.ENOSPC: 388 raise OSError(errno.ENOSPC, "inotify watch limit reached") 389 390 if err == errno.EMFILE: 391 raise OSError(errno.EMFILE, "inotify instance limit reached") 392 393 if err != errno.EACCES: 394 raise OSError(err, os.strerror(err)) 395 396 @staticmethod 397 def _parse_event_buffer(event_buffer: bytes) -> Generator[tuple[int, int, int, bytes]]: 398 """Parses an event buffer of ``inotify_event`` structs returned by 399 inotify:: 400 401 struct inotify_event { 402 __s32 wd; /* watch descriptor */ 403 __u32 mask; /* watch mask */ 404 __u32 cookie; /* cookie to synchronize two events */ 405 __u32 len; /* length (including nulls) of name */ 406 char name[0]; /* stub for possible name */ 407 }; 408 409 The ``cookie`` member of this struct is used to pair two related 410 events, for example, it pairs an IN_MOVED_FROM event with an 411 IN_MOVED_TO event. 412 """ 413 i = 0 414 while i + 16 <= len(event_buffer): 415 wd, mask, cookie, length = struct.unpack_from("iIII", event_buffer, i) 416 name = event_buffer[i + 16 : i + 16 + length].rstrip(b"\0") 417 i += 16 + length 418 yield wd, mask, cookie, name 419 420 421class InotifyEvent: 422 """Inotify event struct wrapper. 423 424 :param wd: 425 Watch descriptor 426 :param mask: 427 Event mask 428 :param cookie: 429 Event cookie 430 :param name: 431 Base name of the event source path. 432 :param src_path: 433 Full event source path. 434 """ 435 436 def __init__(self, wd: int, mask: int, cookie: int, name: bytes, src_path: bytes) -> None: 437 self._wd = wd 438 self._mask = mask 439 self._cookie = cookie 440 self._name = name 441 self._src_path = src_path 442 443 @property 444 def src_path(self) -> bytes: 445 return self._src_path 446 447 @property 448 def wd(self) -> int: 449 return self._wd 450 451 @property 452 def mask(self) -> int: 453 return self._mask 454 455 @property 456 def cookie(self) -> int: 457 return self._cookie 458 459 @property 460 def name(self) -> bytes: 461 return self._name 462 463 @property 464 def is_modify(self) -> bool: 465 return self._mask & InotifyConstants.IN_MODIFY > 0 466 467 @property 468 def is_close_write(self) -> bool: 469 return self._mask & InotifyConstants.IN_CLOSE_WRITE > 0 470 471 @property 472 def is_close_nowrite(self) -> bool: 473 return self._mask & InotifyConstants.IN_CLOSE_NOWRITE > 0 474 475 @property 476 def is_open(self) -> bool: 477 return self._mask & InotifyConstants.IN_OPEN > 0 478 479 @property 480 def is_access(self) -> bool: 481 return self._mask & InotifyConstants.IN_ACCESS > 0 482 483 @property 484 def is_delete(self) -> bool: 485 return self._mask & InotifyConstants.IN_DELETE > 0 486 487 @property 488 def is_delete_self(self) -> bool: 489 return self._mask & InotifyConstants.IN_DELETE_SELF > 0 490 491 @property 492 def is_create(self) -> bool: 493 return self._mask & InotifyConstants.IN_CREATE > 0 494 495 @property 496 def is_moved_from(self) -> bool: 497 return self._mask & InotifyConstants.IN_MOVED_FROM > 0 498 499 @property 500 def is_moved_to(self) -> bool: 501 return self._mask & InotifyConstants.IN_MOVED_TO > 0 502 503 @property 504 def is_move(self) -> bool: 505 return self._mask & InotifyConstants.IN_MOVE > 0 506 507 @property 508 def is_move_self(self) -> bool: 509 return self._mask & InotifyConstants.IN_MOVE_SELF > 0 510 511 @property 512 def is_attrib(self) -> bool: 513 return self._mask & InotifyConstants.IN_ATTRIB > 0 514 515 @property 516 def is_ignored(self) -> bool: 517 return self._mask & InotifyConstants.IN_IGNORED > 0 518 519 @property 520 def is_directory(self) -> bool: 521 # It looks like the kernel does not provide this information for 522 # IN_DELETE_SELF and IN_MOVE_SELF. In this case, assume it's a dir. 523 # See also: https://github.com/seb-m/pyinotify/blob/2c7e8f8/python2/pyinotify.py#L897 524 return self.is_delete_self or self.is_move_self or self._mask & InotifyConstants.IN_ISDIR > 0 525 526 @property 527 def key(self) -> tuple[bytes, int, int, int, bytes]: 528 return self._src_path, self._wd, self._mask, self._cookie, self._name 529 530 def __eq__(self, inotify_event: object) -> bool: 531 if not isinstance(inotify_event, InotifyEvent): 532 return NotImplemented 533 return self.key == inotify_event.key 534 535 def __ne__(self, inotify_event: object) -> bool: 536 if not isinstance(inotify_event, InotifyEvent): 537 return NotImplemented 538 return self.key != inotify_event.key 539 540 def __hash__(self) -> int: 541 return hash(self.key) 542 543 @staticmethod 544 def _get_mask_string(mask: int) -> str: 545 masks = [] 546 for c in dir(InotifyConstants): 547 if c.startswith("IN_") and c not in {"IN_ALL_EVENTS", "IN_MOVE"}: 548 c_val = getattr(InotifyConstants, c) 549 if mask & c_val: 550 masks.append(c) 551 return "|".join(masks) 552 553 def __repr__(self) -> str: 554 return ( 555 f"<{type(self).__name__}: src_path={self.src_path!r}, wd={self.wd}," 556 f" mask={self._get_mask_string(self.mask)}, cookie={self.cookie}," 557 f" name={os.fsdecode(self.name)!r}>" 558 ) 559