robotframework-filewatcher 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,132 @@
1
+ from pathlib import Path
2
+ from robot.api.deco import keyword
3
+ from FileWatcher.watcher import WatchManager
4
+
5
+
6
+ class WatchingKeywords:
7
+ """Keywords for managing watched directories."""
8
+
9
+ def __init__(self, ctx: any) -> None:
10
+ """Initializes the WatchingKeywords component.
11
+
12
+ Args:
13
+ ctx: The parent FileWatcherLibrary context.
14
+ """
15
+ self.ctx = ctx
16
+
17
+ @keyword("Start Watching Directory")
18
+ def start_watching_directory(self, path: str, recursive: bool = True) -> None:
19
+ """Start monitoring a directory for filesystem events.
20
+
21
+ This keyword instructs the library to monitor the given directory
22
+ and begin collecting filesystem events (create/modify/delete/move)
23
+ into the internal EventStore. Observers run in the background
24
+ so tests can continue while events are collected.
25
+
26
+ Arguments:
27
+ path: Directory path to monitor. Can be relative or absolute.
28
+ recursive: If True (default), subdirectories are monitored too.
29
+
30
+ Returns:
31
+ None
32
+
33
+ Raises:
34
+ FileWatcherError: If the path is not a directory or observer fails to start.
35
+
36
+ *Examples*
37
+ | ***** Settings *****
38
+ | Library FileWatcher
39
+ |
40
+ | ***** Test Cases *****
41
+ | Example
42
+ | Start Watching Directory /path/to/watch recursive=False
43
+ """
44
+ self.ctx.watch_manager.start_watch(path, recursive=recursive)
45
+
46
+ @keyword("Stop Watching Directory")
47
+ def stop_watching_directory(self, path: str) -> None:
48
+ """Stop monitoring a previously watched directory.
49
+
50
+ Arguments:
51
+ path: Directory path that was previously started with
52
+ `Start Watching Directory`. The path is resolved when
53
+ matching active watchers to stop.
54
+
55
+ Returns:
56
+ None
57
+
58
+ Raises:
59
+ FileWatcherError: If there is no active watcher for the given path.
60
+
61
+ *Examples*
62
+ | ***** Settings *****
63
+ | Library FileWatcher
64
+ |
65
+ | ***** Test Cases *****
66
+ | Example
67
+ | Start Watching Directory ${DOWNLOADS}
68
+ | ${is_watching}= Is Watching Directory ${DOWNLOADS}
69
+ | Should Be True ${is_watching}
70
+ | Stop Watching Directory ${DOWNLOADS}
71
+ """
72
+ self.ctx.watch_manager.stop_watch(path)
73
+
74
+ @keyword("Is Watching Directory")
75
+ def is_watching_directory(self, path: str) -> bool:
76
+ """Return whether the library currently monitors the given directory.
77
+
78
+ Arguments:
79
+ path: Directory path to check. Can be relative or absolute.
80
+
81
+ Returns:
82
+ bool: True if an active watcher exists for the resolved path,
83
+ otherwise False.
84
+
85
+ *Examples*
86
+ | ***** Settings *****
87
+ | Library FileWatcher
88
+ |
89
+ | ***** Test Cases *****
90
+ | Example
91
+ | Start Watching Directory ${DOWNLOADS}
92
+ | ${is_watching}= Is Watching Directory ${DOWNLOADS}
93
+ | Should Be True ${is_watching}
94
+ """
95
+ # Normalize the provided path before checking registry membership
96
+ try:
97
+ resolved = str(Path(path).resolve())
98
+ except Exception:
99
+ # Fall back to original value if resolution fails
100
+ resolved = path
101
+ return self.ctx.watch_manager.is_watching(resolved)
102
+
103
+ @keyword("Get Watched Directories")
104
+ def get_watched_directories(self) -> list[str]:
105
+ """Return a sorted list of actively monitored directories.
106
+
107
+ This returns absolute, unique paths for all directories currently
108
+ being observed by the library (duplicates removed).
109
+
110
+ Returns:
111
+ list[str]: Sorted list of absolute paths.
112
+
113
+ *Examples*
114
+ | ***** Settings *****
115
+ | Library FileWatcher
116
+ |
117
+ | ***** Test Cases *****
118
+ | Example
119
+ | ${dirs}= Get Watched Directories
120
+ | Should Contain ${dirs} ${DOWNLOADS}
121
+ """
122
+ paths = self.ctx.watch_manager.watched_directories()
123
+ # Ensure unique, sorted string representation
124
+ seen = set()
125
+ out = []
126
+ for p in paths:
127
+ s = str(p)
128
+ if s in seen:
129
+ continue
130
+ seen.add(s)
131
+ out.append(s)
132
+ return out
FileWatcher/library.py ADDED
@@ -0,0 +1,52 @@
1
+ import importlib.metadata
2
+ from robotlibcore import HybridCore
3
+ from FileWatcher.watcher import WatchManager
4
+ from FileWatcher.keywords.watching import WatchingKeywords
5
+ from FileWatcher.keywords.waiting import WaitingKeywords
6
+
7
+
8
+ class FileWatcher(HybridCore):
9
+ """FileWatcher is a modern Robot Framework library for filesystem monitoring.
10
+
11
+ It uses `watchdog` to monitor directory changes in the background, collects
12
+ events thread-safely in a non-consuming event store, and exposes keywords
13
+ to wait for specific creation, modification, deletion, and stability states.
14
+ """
15
+
16
+ ROBOT_LIBRARY_SCOPE = "GLOBAL"
17
+ ROBOT_AUTO_KEYWORDS = False
18
+
19
+ try:
20
+ ROBOT_LIBRARY_VERSION = importlib.metadata.version("robotframework-filewatcher")
21
+ except importlib.metadata.PackageNotFoundError:
22
+ ROBOT_LIBRARY_VERSION = "0.1.0"
23
+
24
+ def __init__(self, max_events: int = 10000) -> None:
25
+ """Initializes the FileWatcher library.
26
+
27
+ Args:
28
+ max_events (int): The maximum number of historical events to retain.
29
+ """
30
+ self.watch_manager = WatchManager(max_events=int(max_events))
31
+ libraries = [
32
+ WatchingKeywords(self),
33
+ WaitingKeywords(self),
34
+ ]
35
+ super().__init__(libraries)
36
+
37
+ def close(self) -> None:
38
+ """Cleans up and shuts down all active background observers.
39
+
40
+ Called automatically by Robot Framework at the end of the execution.
41
+ """
42
+ self.watch_manager.close()
43
+
44
+ def __enter__(self) -> "FileWatcher":
45
+ return self
46
+
47
+ def __exit__(self, exc_type: any, exc_val: any, exc_tb: any) -> None:
48
+ self.close()
49
+
50
+ def __repr__(self) -> str:
51
+ watching_count = len(self.watch_manager.watched_directories())
52
+ return f"FileWatcher(version={self.ROBOT_LIBRARY_VERSION}, watching={watching_count})"
FileWatcher/models.py ADDED
@@ -0,0 +1,119 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+ import fnmatch
4
+ from pathlib import Path
5
+ import threading
6
+
7
+
8
+ class EventType(str, Enum):
9
+ """Enumeration of file system event types."""
10
+
11
+ CREATED = "created"
12
+ MODIFIED = "modified"
13
+ DELETED = "deleted"
14
+ MOVED = "moved"
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class FileEvent:
19
+ """Immutable representation of a file system event.
20
+
21
+ Attributes:
22
+ id (int): Monotonically increasing sequential ID.
23
+ event_type (EventType): Type of event (created, modified, deleted, moved).
24
+ src_path (Path): Path of the file/directory that triggered the event.
25
+ dest_path (Path | None): Destination path for moved events. Defaults to None.
26
+ watched_directory (Path): The root directory under watch that caught this event.
27
+ timestamp (float): Epoch timestamp when the event was recorded.
28
+ """
29
+
30
+ id: int
31
+ event_type: EventType
32
+ src_path: Path
33
+ watched_directory: Path
34
+ timestamp: float
35
+ dest_path: Path | None = None
36
+
37
+ def matches_glob(self, pattern: str) -> bool:
38
+ """Checks if the event's source or destination path matches the glob pattern.
39
+
40
+ Matches against the path's filename (e.g. 'report.pdf') as well as the
41
+ relative path from the watched directory (e.g. 'sub/report.pdf').
42
+
43
+ Args:
44
+ pattern (str): The glob pattern to match against (e.g. '*.pdf').
45
+
46
+ Returns:
47
+ bool: True if the pattern matches, False otherwise.
48
+ """
49
+ paths_to_check = [self.src_path]
50
+ if self.dest_path is not None:
51
+ paths_to_check.append(self.dest_path)
52
+
53
+ for path in paths_to_check:
54
+ # Match against filename (basename)
55
+ if fnmatch.fnmatch(path.name, pattern):
56
+ return True
57
+
58
+ # Match against relative path from watched directory
59
+ try:
60
+ rel_path = path.relative_to(self.watched_directory)
61
+ if fnmatch.fnmatch(str(rel_path), pattern) or fnmatch.fnmatch(
62
+ rel_path.as_posix(), pattern
63
+ ):
64
+ return True
65
+ except ValueError:
66
+ # Path is not relative to watched_directory (should not normally happen)
67
+ pass
68
+
69
+ return False
70
+
71
+ def matches_directory(self, directory: Path) -> bool:
72
+ """Checks if the event path resides within or matches the given directory.
73
+
74
+ Args:
75
+ directory (Path): The directory path to check.
76
+
77
+ Returns:
78
+ bool: True if the path matches or is a descendant of the directory.
79
+ """
80
+ try:
81
+ resolved_src = self.src_path.resolve()
82
+ resolved_dir = directory.resolve()
83
+ return resolved_dir == resolved_src or resolved_dir in resolved_src.parents
84
+ except (ValueError, OSError):
85
+ # Fallback to lexical comparison if resolution fails
86
+ return directory == self.src_path or directory in self.src_path.parents
87
+
88
+ def to_dict(self) -> dict:
89
+ """Converts the FileEvent to a standard dictionary representation.
90
+
91
+ Returns:
92
+ dict: Dictionary containing serializable event data.
93
+ """
94
+ return {
95
+ "id": self.id,
96
+ "event_type": self.event_type.value,
97
+ "src_path": str(self.src_path),
98
+ "dest_path": str(self.dest_path) if self.dest_path is not None else None,
99
+ "watched_directory": str(self.watched_directory),
100
+ "timestamp": self.timestamp,
101
+ }
102
+
103
+
104
+ class EventIdGenerator:
105
+ """Thread-safe sequential generator for FileEvent IDs."""
106
+
107
+ def __init__(self) -> None:
108
+ self._lock = threading.Lock()
109
+ self._counter = 0
110
+
111
+ def next_id(self) -> int:
112
+ """Generates the next sequential ID starting from 1.
113
+
114
+ Returns:
115
+ int: The next unique integer ID.
116
+ """
117
+ with self._lock:
118
+ self._counter += 1
119
+ return self._counter
FileWatcher/watcher.py ADDED
@@ -0,0 +1,343 @@
1
+ from collections import deque
2
+ from pathlib import Path
3
+ import threading
4
+ import time
5
+ from typing import Iterator
6
+
7
+ from watchdog.events import FileSystemEvent, FileSystemEventHandler
8
+ from watchdog.observers import Observer
9
+ from watchdog.observers.api import ObservedWatch
10
+
11
+ from FileWatcher.exceptions import (
12
+ DirectoryAlreadyWatchedError,
13
+ DirectoryNotWatchedError,
14
+ FileWatcherError,
15
+ FileWatcherTimeoutError,
16
+ )
17
+ from FileWatcher.models import EventIdGenerator, EventType, FileEvent
18
+
19
+
20
+ class EventStore:
21
+ """A thread-safe, non-consuming, in-memory store for file events.
22
+
23
+ Retains history using a double-ended queue (deque) with a configurable maximum size.
24
+ Waiters can block efficiently using a condition variable without busy waiting.
25
+ """
26
+
27
+ def __init__(self, max_events: int = 10000) -> None:
28
+ """Initializes the EventStore.
29
+
30
+ Args:
31
+ max_events (int): The maximum number of events to retain in memory.
32
+ """
33
+ self._max_events = max_events
34
+ self._condition = threading.Condition()
35
+ self._events: deque[FileEvent] = deque(maxlen=max_events)
36
+
37
+ def push(self, event: FileEvent) -> None:
38
+ """Appends a new event to the store and notifies all waiting threads.
39
+
40
+ Args:
41
+ event (FileEvent): The file event to add.
42
+ """
43
+ with self._condition:
44
+ self._events.append(event)
45
+ self._condition.notify_all()
46
+
47
+ def get_all(self) -> list[FileEvent]:
48
+ """Returns a snapshot list of all retained events.
49
+
50
+ Returns:
51
+ list[FileEvent]: A copy of the currently retained events.
52
+ """
53
+ with self._condition:
54
+ return list(self._events)
55
+
56
+ def get_since(self, event_id: int) -> list[FileEvent]:
57
+ """Returns a snapshot list of events with an ID greater than event_id.
58
+
59
+ Args:
60
+ event_id (int): The starting threshold ID.
61
+
62
+ Returns:
63
+ list[FileEvent]: A list of events occurring after event_id.
64
+ """
65
+ with self._condition:
66
+ return [event for event in self._events if event.id > event_id]
67
+
68
+ def get_current_event_id(self) -> int:
69
+ """Returns the ID of the most recent event.
70
+
71
+ Returns:
72
+ int: The maximum event ID currently stored, or 0 if empty.
73
+ """
74
+ with self._condition:
75
+ if not self._events:
76
+ return 0
77
+ return self._events[-1].id
78
+
79
+ def clear(self) -> None:
80
+ """Removes all stored events. Thread-safe."""
81
+ with self._condition:
82
+ self._events.clear()
83
+
84
+ def wait_for_event(
85
+ self,
86
+ event_type: EventType | None = None,
87
+ pattern: str | None = None,
88
+ since_id: int = 0,
89
+ timeout: float = 30.0,
90
+ ) -> FileEvent:
91
+ """Blocks until a matching event is recorded or the timeout is exceeded.
92
+
93
+ First scans the existing history for a match. If not found, blocks
94
+ using a condition variable.
95
+
96
+ Args:
97
+ event_type (EventType | None): Optional type of event to match.
98
+ pattern (str | None): Optional glob pattern to match.
99
+ since_id (int): Match only events with ID greater than this value.
100
+ timeout (float): Maximum time to wait in seconds.
101
+
102
+ Returns:
103
+ FileEvent: The first matching file event.
104
+
105
+ Raises:
106
+ FileWatcherTimeoutError: If no matching event is found within the timeout.
107
+ """
108
+ deadline = time.time() + timeout
109
+ with self._condition:
110
+ while True:
111
+ # 1. Scan current list
112
+ for event in self._events:
113
+ if event.id > since_id:
114
+ if event_type is not None and event.event_type != event_type:
115
+ continue
116
+ if pattern is not None and not event.matches_glob(pattern):
117
+ continue
118
+ return event
119
+
120
+ # 2. Check timeout
121
+ remaining = deadline - time.time()
122
+ if remaining <= 0:
123
+ raise FileWatcherTimeoutError(
124
+ f"Timed out waiting for file event (pattern={pattern}, "
125
+ f"event_type={event_type}, since_id={since_id}) after {timeout} seconds."
126
+ )
127
+
128
+ # 3. Wait for notification
129
+ self._condition.wait(timeout=remaining)
130
+
131
+ def __len__(self) -> int:
132
+ """Returns the number of currently stored events.
133
+
134
+ Returns:
135
+ int: The size of the store.
136
+ """
137
+ with self._condition:
138
+ return len(self._events)
139
+
140
+ def __repr__(self) -> str:
141
+ """Returns string representation of the EventStore.
142
+
143
+ Returns:
144
+ str: Representative string.
145
+ """
146
+ with self._condition:
147
+ return f"EventStore(events={len(self._events)}, max_events={self._max_events})"
148
+
149
+
150
+ class DirectoryEventHandler(FileSystemEventHandler):
151
+ """Bridges watchdog raw OS events to our EventStore using FileEvent models."""
152
+
153
+ def __init__(
154
+ self,
155
+ watched_directory: Path,
156
+ event_store: EventStore,
157
+ id_generator: EventIdGenerator,
158
+ ) -> None:
159
+ """Initializes the DirectoryEventHandler.
160
+
161
+ Args:
162
+ watched_directory (Path): Root directory path monitored by this handler.
163
+ event_store (EventStore): Targets incoming events to this store.
164
+ id_generator (EventIdGenerator): Generator for unique event IDs.
165
+ """
166
+ super().__init__()
167
+ self.watched_directory = watched_directory
168
+ self.event_store = event_store
169
+ self.id_generator = id_generator
170
+
171
+ def on_any_event(self, event: FileSystemEvent) -> None:
172
+ """Handles any filesystem event, normalization, and pushing to EventStore."""
173
+ try:
174
+ event_type = EventType(event.event_type)
175
+ except ValueError:
176
+ # Ignore event types we don't track
177
+ return
178
+
179
+ src_path = Path(event.src_path)
180
+ dest_path = Path(event.dest_path) if getattr(event, "dest_path", None) is not None else None
181
+
182
+ file_event = FileEvent(
183
+ id=self.id_generator.next_id(),
184
+ event_type=event_type,
185
+ src_path=src_path,
186
+ dest_path=dest_path,
187
+ watched_directory=self.watched_directory,
188
+ timestamp=time.time(),
189
+ )
190
+ self.event_store.push(file_event)
191
+
192
+
193
+ class WatchManager:
194
+ """Orchestrates file system observers and directory watches using watchdog."""
195
+
196
+ def __init__(self, max_events: int = 10000) -> None:
197
+ """Initializes the WatchManager.
198
+
199
+ Args:
200
+ max_events (int): Event retention size.
201
+ """
202
+ self._event_store = EventStore(max_events)
203
+ self._event_id_generator = EventIdGenerator()
204
+ self._watches: dict[Path, ObservedWatch] = {}
205
+ self._handlers: dict[Path, DirectoryEventHandler] = {}
206
+ self._lock = threading.RLock()
207
+ self._observer: Observer | None = None
208
+
209
+ def _get_observer(self) -> Observer:
210
+ """Returns the watchdog Observer instance, creating it if not exist."""
211
+ # Must be called under lock
212
+ if self._observer is None:
213
+ self._observer = Observer()
214
+ return self._observer
215
+
216
+ def _ensure_observer_running(self) -> None:
217
+ """Starts the observer background thread if it is not already running."""
218
+ # Must be called under lock
219
+ observer = self._get_observer()
220
+ if not observer.is_alive():
221
+ try:
222
+ observer.start()
223
+ except RuntimeError:
224
+ # The thread has already been stopped once; recreate it
225
+ self._observer = Observer()
226
+ self._observer.start()
227
+
228
+ def start_watch(self, path: Path | str, recursive: bool = True) -> None:
229
+ """Starts monitoring a directory path for events.
230
+
231
+ Args:
232
+ path (Path | str): The directory path to monitor.
233
+ recursive (bool): Whether to watch subdirectories.
234
+
235
+ Raises:
236
+ FileWatcherError: If path is invalid or does not exist.
237
+ DirectoryAlreadyWatchedError: If path is already monitored.
238
+ """
239
+ with self._lock:
240
+ try:
241
+ resolved_path = Path(path).resolve(strict=True)
242
+ except (OSError, FileNotFoundError) as err:
243
+ raise FileWatcherError(f"Path does not exist: {path}") from err
244
+
245
+ if not resolved_path.is_dir():
246
+ raise FileWatcherError(f"Path is not a directory: {path}")
247
+
248
+ if resolved_path in self._watches:
249
+ raise DirectoryAlreadyWatchedError(f"Directory is already watched: {path}")
250
+
251
+ handler = DirectoryEventHandler(
252
+ watched_directory=resolved_path,
253
+ event_store=self._event_store,
254
+ id_generator=self._event_id_generator,
255
+ )
256
+
257
+ observer = self._get_observer()
258
+ self._ensure_observer_running()
259
+
260
+ watch = observer.schedule(handler, str(resolved_path), recursive=recursive)
261
+ self._watches[resolved_path] = watch
262
+ self._handlers[resolved_path] = handler
263
+
264
+ def stop_watch(self, path: Path | str) -> None:
265
+ """Stops monitoring a directory path.
266
+
267
+ Args:
268
+ path (Path | str): The directory path to stop monitoring.
269
+
270
+ Raises:
271
+ DirectoryNotWatchedError: If path was not registered.
272
+ """
273
+ with self._lock:
274
+ resolved_path = Path(path).resolve()
275
+ if resolved_path not in self._watches:
276
+ raise DirectoryNotWatchedError(f"Directory is not currently watched: {path}")
277
+
278
+ watch = self._watches.pop(resolved_path)
279
+ self._handlers.pop(resolved_path)
280
+
281
+ if self._observer is not None:
282
+ self._observer.unschedule(watch)
283
+
284
+ def is_watching(self, path: Path | str) -> bool:
285
+ """Checks if a directory path is actively watched.
286
+
287
+ Args:
288
+ path (Path | str): The directory path.
289
+
290
+ Returns:
291
+ bool: True if watched, False otherwise.
292
+ """
293
+ with self._lock:
294
+ try:
295
+ resolved_path = Path(path).resolve()
296
+ return resolved_path in self._watches
297
+ except (OSError, FileNotFoundError):
298
+ return False
299
+
300
+ def watched_directories(self) -> list[Path]:
301
+ """Returns a sorted list of all active watched paths.
302
+
303
+ Returns:
304
+ list[Path]: Sorted active paths.
305
+ """
306
+ with self._lock:
307
+ return sorted(list(self._watches.keys()))
308
+
309
+ def get_event_store(self) -> EventStore:
310
+ """Returns the shared EventStore instance.
311
+
312
+ Returns:
313
+ EventStore: Shared store.
314
+ """
315
+ return self._event_store
316
+
317
+ def close(self) -> None:
318
+ """Stops the observer and unschedules all directory watches.
319
+
320
+ Safe to call multiple times.
321
+ """
322
+ with self._lock:
323
+ if self._observer is not None:
324
+ self._observer.stop()
325
+ self._observer.join()
326
+ self._observer = None
327
+
328
+ self._watches.clear()
329
+ self._handlers.clear()
330
+
331
+ def __enter__(self) -> "WatchManager":
332
+ return self
333
+
334
+ def __exit__(self, exc_type: any, exc_val: any, exc_tb: any) -> None:
335
+ self.close()
336
+
337
+ def __repr__(self) -> str:
338
+ with self._lock:
339
+ observer_running = self._observer is not None and self._observer.is_alive()
340
+ return (
341
+ f"WatchManager(watching={len(self._watches)}, "
342
+ f"observer_running={observer_running})"
343
+ )