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,4 @@
1
+ from FileWatcher.library import FileWatcher
2
+ from FileWatcher.exceptions import FileWatcherError
3
+
4
+ __all__ = ["FileWatcher", "FileWatcherError"]
@@ -0,0 +1,18 @@
1
+ class FileWatcherError(Exception):
2
+ """Base exception for all FileWatcher library errors."""
3
+ pass
4
+
5
+
6
+ class FileWatcherTimeoutError(FileWatcherError):
7
+ """Raised when a wait operation times out waiting for a file system event."""
8
+ pass
9
+
10
+
11
+ class DirectoryNotWatchedError(FileWatcherError):
12
+ """Raised when trying to perform an action on a directory that is not currently being watched."""
13
+ pass
14
+
15
+
16
+ class DirectoryAlreadyWatchedError(FileWatcherError):
17
+ """Raised when trying to watch a directory that is already registered and watched."""
18
+ pass
@@ -0,0 +1,628 @@
1
+ from pathlib import Path
2
+ import time
3
+ from robot.api.deco import keyword
4
+ from FileWatcher.exceptions import FileWatcherTimeoutError, FileWatcherError
5
+ from FileWatcher.models import EventType
6
+
7
+
8
+ class WaitingKeywords:
9
+ """Keywords for waiting for file events and checking stability."""
10
+
11
+ def __init__(self, ctx: any) -> None:
12
+ """Initializes the WaitingKeywords component.
13
+
14
+ Args:
15
+ ctx: The parent FileWatcherLibrary context.
16
+ """
17
+ self.ctx = ctx
18
+
19
+ @keyword("Wait For File Created")
20
+ def wait_for_file_created(
21
+ self, pattern: str | None = None, since_id: int = 0, timeout: float = 30.0
22
+ ) -> dict:
23
+ """Wait until a creation event matching a pattern appears.
24
+
25
+ This keyword blocks until an event of type 'created' that matches the
26
+ optional glob `pattern` is observed in the EventStore or the
27
+ `timeout` is reached.
28
+
29
+ Arguments:
30
+ pattern: Optional glob pattern (for example "*.pdf"). If omitted,
31
+ any created file will match.
32
+ since_id: Only consider events with ID greater than this value.
33
+ timeout: Maximum seconds to wait before raising a timeout error.
34
+
35
+ Returns:
36
+ dict: A dictionary representing the matched event. Typical keys
37
+ include 'id', 'event_type', 'src_path', 'dest_path',
38
+ 'watched_directory', and 'timestamp'.
39
+
40
+ Raises:
41
+ FileWatcherTimeoutError: When no matching event appears before timeout.
42
+
43
+ *Examples*
44
+ | ***** Settings *****
45
+ | Library FileWatcher
46
+ |
47
+ | ***** Test Cases *****
48
+ | Example
49
+ | ${event} Wait For File Created *.pdf
50
+ | ${event} Wait For File Created report.xlsx since_id=12 timeout=15
51
+ """
52
+ store = self.ctx.watch_manager.get_event_store()
53
+ event = store.wait_for_event(
54
+ event_type=EventType.CREATED,
55
+ pattern=pattern,
56
+ since_id=int(since_id),
57
+ timeout=float(timeout),
58
+ )
59
+ return event.to_dict()
60
+
61
+ @keyword("Wait For File Modified")
62
+ def wait_for_file_modified(
63
+ self, pattern: str | None = None, since_id: int = 0, timeout: float = 30.0
64
+ ) -> dict:
65
+ """Wait until a file modification event matching `pattern` appears.
66
+
67
+ Arguments:
68
+ pattern: Optional glob pattern to filter modified files.
69
+ since_id: Only consider events with ID greater than this value.
70
+ timeout: Maximum seconds to wait.
71
+
72
+ Returns:
73
+ dict: Dictionary representation of the matched modification event.
74
+
75
+ Raises:
76
+ FileWatcherTimeoutError: When no matching event appears before timeout.
77
+
78
+ *Examples*
79
+ | ***** Settings *****
80
+ | Library FileWatcher
81
+ |
82
+ | ***** Test Cases *****
83
+ | Example
84
+ | ${event} Wait For File Modified *.pdf
85
+ """
86
+ store = self.ctx.watch_manager.get_event_store()
87
+ event = store.wait_for_event(
88
+ event_type=EventType.MODIFIED,
89
+ pattern=pattern,
90
+ since_id=int(since_id),
91
+ timeout=float(timeout),
92
+ )
93
+ return event.to_dict()
94
+
95
+ @keyword("Wait For File Deleted")
96
+ def wait_for_file_deleted(
97
+ self, pattern: str | None = None, since_id: int = 0, timeout: float = 30.0
98
+ ) -> dict:
99
+ """Wait until a file deletion event matching `pattern` appears.
100
+
101
+ Arguments:
102
+ pattern: Optional glob pattern to filter deleted files.
103
+ since_id: Only consider events with ID greater than this value.
104
+ timeout: Maximum seconds to wait.
105
+
106
+ Returns:
107
+ dict: Dictionary representation of the matched deletion event.
108
+
109
+ Raises:
110
+ FileWatcherTimeoutError: When no matching event appears before timeout.
111
+
112
+ *Examples*
113
+ | ***** Settings *****
114
+ | Library FileWatcher
115
+ |
116
+ | ***** Test Cases *****
117
+ | Example
118
+ | ${event} Wait For File Deleted *.pdf
119
+ """
120
+ store = self.ctx.watch_manager.get_event_store()
121
+ event = store.wait_for_event(
122
+ event_type=EventType.DELETED,
123
+ pattern=pattern,
124
+ since_id=int(since_id),
125
+ timeout=float(timeout),
126
+ )
127
+ return event.to_dict()
128
+
129
+ @keyword("Wait Until File Stable")
130
+ def wait_until_file_stable(
131
+ self, pattern: str, stability_time: float = 2.0, timeout: float = 30.0
132
+ ) -> dict:
133
+ """Wait until a file matching `pattern` has stabilized.
134
+
135
+ Resolution strategy:
136
+ 1. Check EventStore for recent events matching `pattern`.
137
+ 2. Scan watched directories on disk.
138
+ 3. Block and wait for a new matching event if not found.
139
+
140
+ After resolving a candidate file, the function watches for both
141
+ filesystem size changes and new events for `stability_time` seconds
142
+ to consider the file stable.
143
+
144
+ Arguments:
145
+ pattern: Glob pattern to identify the file (required).
146
+ stability_time: Seconds the file must remain unchanged.
147
+ timeout: Maximum seconds to wait before raising a timeout.
148
+
149
+ Returns:
150
+ dict: Event dictionary representing the stable file event.
151
+
152
+ Raises:
153
+ FileWatcherTimeoutError: If file cannot be resolved or does not
154
+ stabilize before `timeout`.
155
+
156
+ *Examples*
157
+ | ***** Settings *****
158
+ | Library FileWatcher
159
+ |
160
+ | ***** Test Cases *****
161
+ | Example
162
+ | ${event} Wait Until File Stable *.pdf stability_time=5 timeout=60
163
+ """
164
+ stability_time = float(stability_time)
165
+ timeout = float(timeout)
166
+ deadline = time.time() + timeout
167
+ store = self.ctx.watch_manager.get_event_store()
168
+
169
+ matched_path: Path | None = None
170
+
171
+ # 1. EventStore-first: Check recorded history for any events matching the pattern
172
+ matching_events = [
173
+ e for e in store.get_all() if e.matches_glob(pattern)
174
+ ]
175
+ if matching_events:
176
+ latest_event = matching_events[-1]
177
+ matched_path = (
178
+ latest_event.src_path
179
+ if latest_event.event_type != EventType.MOVED
180
+ else latest_event.dest_path
181
+ )
182
+
183
+ # 2. Filesystem-second: Scan watched directories on disk
184
+ if matched_path is None:
185
+ for watched_dir in self.ctx.watch_manager.watched_directories():
186
+ try:
187
+ for p in watched_dir.rglob(pattern):
188
+ if p.is_file():
189
+ matched_path = p
190
+ break
191
+ except Exception:
192
+ pass
193
+ if matched_path is not None:
194
+ break
195
+
196
+ # 3. Block and wait for a new event to arrive if not resolved
197
+ if matched_path is None:
198
+ remaining = deadline - time.time()
199
+ if remaining <= 0:
200
+ raise FileWatcherTimeoutError(
201
+ f"Timed out waiting for file matching pattern '{pattern}' to exist."
202
+ )
203
+ event = store.wait_for_event(pattern=pattern, timeout=remaining)
204
+ matched_path = (
205
+ event.src_path if event.event_type != EventType.MOVED else event.dest_path
206
+ )
207
+ assert matched_path is not None
208
+
209
+ # 4. Once path is resolved, verify stability
210
+ last_check_time = time.time()
211
+ last_size = None
212
+ if matched_path.exists():
213
+ last_size = matched_path.stat().st_size
214
+
215
+ while True:
216
+ remaining = deadline - time.time()
217
+ if remaining <= 0:
218
+ raise FileWatcherTimeoutError(
219
+ f"Timed out waiting for file '{matched_path}' to stabilize."
220
+ )
221
+
222
+ # Sleep for stability_time or remaining time
223
+ sleep_time = min(stability_time, remaining)
224
+ time.sleep(sleep_time)
225
+
226
+ if not matched_path.exists():
227
+ raise FileWatcherTimeoutError(
228
+ f"File '{matched_path}' was deleted or does not exist during stability check."
229
+ )
230
+
231
+ current_size = matched_path.stat().st_size
232
+
233
+ # Check if any new events were recorded for this file
234
+ new_events = [
235
+ e
236
+ for e in store.get_all()
237
+ if e.timestamp > last_check_time
238
+ and (e.src_path == matched_path or e.dest_path == matched_path)
239
+ and e.event_type
240
+ in (EventType.MODIFIED, EventType.MOVED, EventType.CREATED)
241
+ ]
242
+
243
+ if len(new_events) > 0 or current_size != last_size:
244
+ # File is still active. Reset timer.
245
+ last_size = current_size
246
+ last_check_time = time.time()
247
+ continue
248
+
249
+ # No changes during the interval. It is stable!
250
+ # Find the last matching event in the store to return
251
+ all_file_events = [
252
+ e
253
+ for e in store.get_all()
254
+ if (e.src_path == matched_path or e.dest_path == matched_path)
255
+ ]
256
+ if all_file_events:
257
+ return all_file_events[-1].to_dict()
258
+ else:
259
+ # Fallback dictionary if it existed pre-watch and no events are recorded
260
+ return {
261
+ "id": 0,
262
+ "event_type": "stable",
263
+ "src_path": str(matched_path),
264
+ "dest_path": None,
265
+ "watched_directory": "",
266
+ "timestamp": time.time(),
267
+ }
268
+
269
+ @keyword("Get File Events")
270
+ def get_file_events(self) -> list[dict]:
271
+ """Return all events currently retained in the EventStore.
272
+
273
+ Returns:
274
+ list[dict]: A list of event dictionaries ordered by increasing ID.
275
+
276
+ *Examples*
277
+ | ***** Settings *****
278
+ | Library FileWatcher
279
+ |
280
+ | ***** Test Cases *****
281
+ | Example
282
+ | ${events} Get File Events
283
+ """
284
+ store = self.ctx.watch_manager.get_event_store()
285
+ return [e.to_dict() for e in store.get_all()]
286
+
287
+ @keyword("Get File Events Since")
288
+ def get_file_events_since(self, event_id: int) -> list[dict]:
289
+ """Return events whose ID is strictly greater than `event_id`.
290
+
291
+ Arguments:
292
+ event_id: Integer event ID to compare.
293
+
294
+ Returns:
295
+ list[dict]: Matching events with ID > event_id.
296
+
297
+ *Examples*
298
+ | ***** Settings *****
299
+ | Library FileWatcher
300
+ |
301
+ | ***** Test Cases *****
302
+ | Example
303
+ | ${new_events} Get File Events Since 42
304
+ """
305
+ store = self.ctx.watch_manager.get_event_store()
306
+ return [e.to_dict() for e in store.get_since(int(event_id))]
307
+
308
+ @keyword("Clear Event History")
309
+ def clear_event_history(self) -> None:
310
+ """Clear all events from the in-memory EventStore.
311
+
312
+ Use this to reset test state when previous events should not influence
313
+ subsequent assertions or waits.
314
+
315
+ *Examples*
316
+ | ***** Settings *****
317
+ | Library FileWatcher
318
+ |
319
+ | ***** Test Cases *****
320
+ | Example
321
+ | Clear Event History
322
+ """
323
+ store = self.ctx.watch_manager.get_event_store()
324
+ store.clear()
325
+
326
+ # Commenting this keyword for now since it has a few issues
327
+ # @keyword("Wait For Download")
328
+ def _wait_for_download(
329
+ self, pattern: str, stability_time: float = 2.0, timeout: float = 30.0
330
+ ) -> dict:
331
+ """(Internal) Wait for a download to stabilize.
332
+
333
+ Alias for `Wait Until File Stable`. Kept as a private helper for
334
+ historical reasons and not exported as a Robot keyword.
335
+
336
+ Returns:
337
+ dict: The stable FileEvent dictionary.
338
+
339
+ *Examples*
340
+ | ***** Settings *****
341
+ | Library FileWatcher
342
+ |
343
+ | ***** Test Cases *****
344
+ | Example
345
+ | ${event} Wait For Download *.pdf stability_time=5 timeout=60
346
+ """
347
+ return self.wait_until_file_stable(pattern, stability_time, timeout)
348
+
349
+ @keyword("Wait Until Directory Is Not Empty")
350
+ def wait_until_directory_is_not_empty(
351
+ self, path: str | Path, timeout: float = 30.0
352
+ ) -> None:
353
+ """Block until the directory has at least one file or subdirectory.
354
+
355
+ Arguments:
356
+ path: Directory to monitor.
357
+ timeout: Maximum seconds to wait.
358
+
359
+ Raises:
360
+ FileWatcherTimeoutError: If the directory remains empty until timeout.
361
+
362
+ *Examples*
363
+ | ***** Settings *****
364
+ | Library FileWatcher
365
+ |
366
+ | ***** Test Cases *****
367
+ | Example
368
+ | Wait Until Directory Is Not Empty /path/to/directory timeout=15
369
+ """
370
+ resolved_path = Path(path).resolve()
371
+ if not resolved_path.exists() or not resolved_path.is_dir():
372
+ raise FileWatcherError(f"Directory does not exist: {path}")
373
+
374
+ deadline = time.time() + float(timeout)
375
+ while True:
376
+ if any(resolved_path.iterdir()):
377
+ return
378
+ if time.time() >= deadline:
379
+ raise FileWatcherTimeoutError(
380
+ f"Timed out waiting for directory '{resolved_path}' to become non-empty."
381
+ )
382
+ time.sleep(0.2)
383
+
384
+ @keyword("Wait Until File Count Is")
385
+ def wait_until_file_count_is(
386
+ self,
387
+ path: str | Path,
388
+ count: int,
389
+ pattern: str = "*",
390
+ timeout: float = 30.0,
391
+ ) -> bool:
392
+ """Block until the number of files matching `pattern` equals `count`.
393
+
394
+ Arguments:
395
+ path: Directory to scan.
396
+ count: Expected number of matching files.
397
+ pattern: Glob pattern to count. Defaults to '*'.
398
+ timeout: Maximum seconds to wait.
399
+
400
+ Returns:
401
+ bool: True when the count matches.
402
+
403
+ Raises:
404
+ FileWatcherTimeoutError: If the count does not match before timeout.
405
+
406
+ *Examples*
407
+ | ***** Settings *****
408
+ | Library FileWatcher
409
+ |
410
+ | ***** Test Cases *****
411
+ | Example
412
+ | Wait Until File Count Is /path/to/directory 5 *.pdf
413
+ """
414
+ resolved_path = Path(path).resolve()
415
+ if not resolved_path.exists() or not resolved_path.is_dir():
416
+ raise FileWatcherError(f"Directory does not exist: {path}")
417
+
418
+ deadline = time.time() + float(timeout)
419
+ while True:
420
+ current_count = len(list(resolved_path.glob(pattern)))
421
+ if current_count == int(count):
422
+ return True
423
+ if time.time() >= deadline:
424
+ raise FileWatcherTimeoutError(
425
+ f"Timed out waiting for {count} files matching '{pattern}' in '{resolved_path}'."
426
+ )
427
+ time.sleep(0.2)
428
+
429
+ @keyword("Find Files Matching Pattern")
430
+ def find_files_matching_pattern(
431
+ self, pattern: str, recursive: bool = True
432
+ ) -> list[str]:
433
+ """Search watched directories for files matching `pattern`.
434
+
435
+ Arguments:
436
+ pattern: Glob pattern to search for.
437
+ recursive: Whether to search recursively in subdirectories.
438
+
439
+ Returns:
440
+ list[str]: Sorted unique absolute file paths matching the pattern.
441
+
442
+ *Examples*
443
+ | ***** Settings *****
444
+ | Library FileWatcher
445
+ |
446
+ | ***** Test Cases *****
447
+ | Example
448
+ | @{files} Find Files Matching Pattern *.pdf
449
+ | Log Found @{files}
450
+ """
451
+ wm = self.ctx.watch_manager
452
+ files: list[str] = []
453
+ seen: set[str] = set()
454
+
455
+ with wm._lock:
456
+ for watched_dir in wm.watched_directories():
457
+ try:
458
+ iterator = (
459
+ watched_dir.rglob(pattern)
460
+ if recursive
461
+ else watched_dir.glob(pattern)
462
+ )
463
+ for p in iterator:
464
+ if p.is_file():
465
+ absolute_path = str(p.resolve())
466
+ if absolute_path not in seen:
467
+ seen.add(absolute_path)
468
+ files.append(absolute_path)
469
+ except Exception:
470
+ pass
471
+
472
+ return sorted(files)
473
+
474
+ @keyword("Get File Count")
475
+ def get_file_count(
476
+ self, pattern: str = "*", recursive: bool = True
477
+ ) -> int:
478
+ """Return the number of files matching `pattern` across watched dirs.
479
+
480
+ Arguments:
481
+ pattern: Glob pattern to count. Defaults to '*'.
482
+ recursive: Whether to search recursively (default True).
483
+
484
+ Returns:
485
+ int: Number of matching files.
486
+
487
+ *Examples*
488
+ | ***** Settings *****
489
+ | Library FileWatcher
490
+ |
491
+ | ***** Test Cases *****
492
+ | Example
493
+ | ${count} Get File Count *.pdf
494
+ | Log Found ${count} PDF files.
495
+ """
496
+ return len(self.find_files_matching_pattern(pattern, recursive))
497
+
498
+ @keyword("Get Current Event Id")
499
+ def get_current_event_id(self) -> int:
500
+ """Return the numeric ID of the most recent event in the EventStore.
501
+
502
+ Returns:
503
+ int: Maximum event ID currently stored, or 0 when empty.
504
+
505
+ *Examples*
506
+ | ***** Settings *****
507
+ | Library FileWatcher
508
+ |
509
+ | ***** Test Cases *****
510
+ | Example
511
+ | ${current_id} Get Current Event Id
512
+ """
513
+ store = self.ctx.watch_manager.get_event_store()
514
+ return store.get_current_event_id()
515
+
516
+ @keyword("Get Event Types")
517
+ def get_event_types(self) -> list[str]:
518
+ """Return the list of supported event type names.
519
+
520
+ This is useful for callers of `Should Have File Event` to know which
521
+ values are accepted for the `event_type` parameter.
522
+
523
+ Returns:
524
+ list[str]: ['created', 'modified', 'deleted', 'moved']
525
+
526
+ *Examples*
527
+ | ***** Settings *****
528
+ | Library FileWatcher
529
+ |
530
+ | ***** Test Cases *****
531
+ | Example
532
+ | @{types} Get Event Types
533
+ | Log @{types}
534
+ """
535
+ from FileWatcher.models import EventType
536
+
537
+ return [e.value for e in EventType]
538
+
539
+ @keyword("Get Latest File")
540
+ def get_latest_file(self, pattern: str | None = None, limit: int = 1) -> str | list[str]:
541
+ """Searches all watched directories for files matching the pattern.
542
+
543
+ Sorts them by modification time descending and returns the latest file(s).
544
+
545
+ *Examples*
546
+ | ***** Settings *****
547
+ | Library FileWatcher
548
+ |
549
+ | ***** Test Cases *****
550
+ | Example
551
+ | @{latest_files} Get Latest File *.pdf limit=5
552
+ """
553
+ limit = int(limit)
554
+ glob_pattern = pattern if pattern else "*"
555
+ wm = self.ctx.watch_manager
556
+ files_found = []
557
+
558
+ with wm._lock:
559
+ for resolved_path, watch in wm._watches.items():
560
+ is_recursive = watch.is_recursive
561
+ try:
562
+ generator = resolved_path.rglob(glob_pattern) if is_recursive else resolved_path.glob(glob_pattern)
563
+ for p in generator:
564
+ try:
565
+ if p.is_file():
566
+ mtime = p.stat().st_mtime
567
+ files_found.append((p, mtime))
568
+ except (OSError, FileNotFoundError):
569
+ pass
570
+ except Exception:
571
+ pass
572
+
573
+ if not files_found:
574
+ raise FileWatcherError(
575
+ f"No files matching pattern '{glob_pattern}' found in watched directories."
576
+ )
577
+
578
+ # Sort descending by mtime
579
+ files_found.sort(key=lambda x: x[1], reverse=True)
580
+ str_paths = [str(item[0]) for item in files_found]
581
+
582
+ if limit == 1:
583
+ return str_paths[0]
584
+ else:
585
+ return str_paths[:limit]
586
+
587
+ @keyword("Should Have File Event")
588
+ def should_have_file_event(self, event_type: str | None = None, pattern: str | None = None) -> None:
589
+ """Assert the EventStore contains at least one matching event.
590
+
591
+ Arguments:
592
+ event_type: Optional event type string. Valid values are:
593
+ 'created', 'modified', 'deleted', 'moved'. Use
594
+ `Get Event Types` to retrieve the current supported list.
595
+ pattern: Optional glob pattern to filter events.
596
+
597
+ Raises:
598
+ AssertionError: When no event matching the filters exists.
599
+
600
+ *Examples*
601
+ | ***** Settings *****
602
+ | Library FileWatcher
603
+ |
604
+ | ***** Test Cases *****
605
+ | Example
606
+ | Should Have File Event event_type=created pattern=*.pdf
607
+ """
608
+ store = self.ctx.watch_manager.get_event_store()
609
+ events = store.get_all()
610
+
611
+ target_type = None
612
+ if event_type is not None:
613
+ try:
614
+ target_type = EventType(event_type.lower())
615
+ except ValueError:
616
+ pass
617
+
618
+ for event in events:
619
+ if target_type is not None and event.event_type != target_type:
620
+ continue
621
+ if pattern is not None and not event.matches_glob(pattern):
622
+ continue
623
+ return
624
+
625
+ raise AssertionError(
626
+ f"Assertion Failed: No event matching (event_type={event_type}, pattern={pattern}) "
627
+ f"was found in the EventStore history (total events: {len(events)})."
628
+ )