Skip to content

Commit 7fd8e46

Browse files
Report the type of change observed in FileWatcher (#87)
2 parents 8ae4186 + 7469704 commit 7fd8e46

File tree

4 files changed

+44
-18
lines changed

4 files changed

+44
-18
lines changed

RELEASE_NOTES.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ This release adds support to pass `None` values via channels and revamps the `Ti
88

99
* `util.Timer` was replaced by a more generic implementation that allows for customizable policies to handle missed ticks.
1010

11-
If you were using `Timer` to implement timeouts, these two pices of code should be almost equivalent:
11+
If you were using `Timer` to implement timeouts, these two pieces of code should be almost equivalent:
1212

1313
- Old:
1414

@@ -27,7 +27,7 @@ This release adds support to pass `None` values via channels and revamps the `Ti
2727

2828
They are not **exactly** the same because the `triggered_datetime` in the second case will not be exactly when the timer had triggered, but that shouldn't be relevant, the important part is when your code can actually react to the timer trigger and to know how much drift there was to be able to take corrective actions.
2929

30-
Also the new `Timer` uses the `asyncio` loop monotonic clock and the old one used the wall clock (`datetime.now()`) to track time. This means that when using `async-solipsism` to test, the new `Timer` will always trigger immediately regarless of the state of the wall clock. This also means that we don't need to mock the wall clock with `time-machine` either now.
30+
Also the new `Timer` uses the `asyncio` loop monotonic clock and the old one used the wall clock (`datetime.now()`) to track time. This means that when using `async-solipsism` to test, the new `Timer` will always trigger immediately regardless of the state of the wall clock. This also means that we don't need to mock the wall clock with `time-machine` either now.
3131

3232
With the previous timer one needed to create a separate task to run the timer, because otherwise it would block as it loops until the wall clock was at a specific time. Now the code will run like this:
3333

@@ -39,7 +39,7 @@ This release adds support to pass `None` values via channels and revamps the `Ti
3939

4040
# Simulates a delay in the timer trigger time
4141
asyncio.sleep(1.5) # Advances the loop monotonic clock by 1.5 seconds immediately
42-
await drift = timer.receive() # The timer should have triggerd 0.5 seconds ago, so it doesn't even sleep
42+
await drift = timer.receive() # The timer should have triggered 0.5 seconds ago, so it doesn't even sleep
4343
assert drift == approx(timedelta(seconds=0.5)) # Now we should observe a drift of 0.5 seconds
4444
```
4545

@@ -53,12 +53,19 @@ This release adds support to pass `None` values via channels and revamps the `Ti
5353

5454
Therefore, you should now check a file receiving an event really exist before trying to operate on it.
5555

56+
* `FileWatcher` reports the type of event observed in addition to the file path.
57+
58+
Previously, only the file path was reported. With this update, users can now determine if a file change is a creation, modification, or deletion.
59+
Note that this change may require updates to your existing code that relies on `FileWatcher` as the new interface returns a `FileWatcher.Event` instead of just the file path.
60+
5661
## New Features
5762

5863
* `util.Timer` was replaced by a more generic implementation that allows for customizable policies to handle missed ticks.
5964

6065
* Passing `None` values via channels is now supported.
6166

67+
* `FileWatcher.Event` was added to notify events when a file is created, modified, or deleted.
68+
6269
## Bug Fixes
6370

6471
* `util.Select` / `util.Merge` / `util.MergeNamed`: Cancel pending tasks in `__del__` methods only if possible (the loop is not already closed).

src/frequenz/channels/util/_file_watcher.py

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
# Copyright © 2022 Frequenz Energy-as-a-Service GmbH
33

44
"""A Channel receiver for watching for new (or modified) files."""
5+
6+
from __future__ import annotations
7+
58
import asyncio
69
import pathlib
10+
from dataclasses import dataclass
711
from enum import Enum
8-
from typing import List, Optional, Set, Union
912

1013
from watchfiles import Change, awatch
1114
from watchfiles.main import FileChange
@@ -14,7 +17,7 @@
1417
from .._exceptions import ReceiverStoppedError
1518

1619

17-
class FileWatcher(Receiver[pathlib.Path]):
20+
class FileWatcher(Receiver["FileWatcher.Event"]):
1821
"""A channel receiver that watches for file events."""
1922

2023
class EventType(Enum):
@@ -24,10 +27,19 @@ class EventType(Enum):
2427
MODIFY = Change.modified
2528
DELETE = Change.deleted
2629

30+
@dataclass(frozen=True)
31+
class Event:
32+
"""A file change event."""
33+
34+
type: FileWatcher.EventType
35+
"""The type of change that was observed."""
36+
path: pathlib.Path
37+
"""The path where the change was observed."""
38+
2739
def __init__(
2840
self,
29-
paths: List[Union[pathlib.Path, str]],
30-
event_types: Optional[Set[EventType]] = None,
41+
paths: list[pathlib.Path | str],
42+
event_types: set[EventType] | None = None,
3143
) -> None:
3244
"""Create a `FileWatcher` instance.
3345
@@ -48,8 +60,8 @@ def __init__(
4860
self._awatch = awatch(
4961
*self._paths, stop_event=self._stop_event, watch_filter=self._filter_events
5062
)
51-
self._awatch_stopped_exc: Optional[Exception] = None
52-
self._changes: Set[FileChange] = set()
63+
self._awatch_stopped_exc: Exception | None = None
64+
self._changes: set[FileChange] = set()
5365

5466
def _filter_events(
5567
self,
@@ -102,11 +114,11 @@ async def ready(self) -> bool:
102114

103115
return True
104116

105-
def consume(self) -> pathlib.Path:
106-
"""Return the latest change once `ready` is complete.
117+
def consume(self) -> Event:
118+
"""Return the latest event once `ready` is complete.
107119
108120
Returns:
109-
The next change that was received.
121+
The next event that was received.
110122
111123
Raises:
112124
ReceiverStoppedError: if there is some problem with the receiver.
@@ -115,8 +127,8 @@ def consume(self) -> pathlib.Path:
115127
raise ReceiverStoppedError(self) from self._awatch_stopped_exc
116128

117129
assert self._changes, "`consume()` must be preceeded by a call to `ready()`"
118-
change = self._changes.pop()
119130
# Tuple of (Change, path) returned by watchfiles
120-
_, path_str = change
121-
path = pathlib.Path(path_str)
122-
return path
131+
change, path_str = self._changes.pop()
132+
return FileWatcher.Event(
133+
type=FileWatcher.EventType(change), path=pathlib.Path(path_str)
134+
)

tests/utils/test_file_watcher.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ async def test_file_watcher_receive_updates(
7171

7272
for change in changes:
7373
recv_changes = await file_watcher.receive()
74-
assert recv_changes == pathlib.Path(change[1])
74+
event_type = FileWatcher.EventType(change[0])
75+
path = pathlib.Path(change[1])
76+
assert recv_changes == FileWatcher.Event(type=event_type, path=path)
7577

7678

7779
@hypothesis.given(event_types=st.sets(st.sampled_from(FileWatcher.EventType)))

tests/utils/test_integration.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,12 @@ async def test_file_watcher(tmp_path: pathlib.Path) -> None:
3636
if msg := select.timer:
3737
filename.write_text(f"{msg.inner}")
3838
elif msg := select.file_watcher:
39-
assert msg.inner == filename
39+
event_type = (
40+
FileWatcher.EventType.CREATE
41+
if number_of_writes == 0
42+
else FileWatcher.EventType.MODIFY
43+
)
44+
assert msg.inner == FileWatcher.Event(type=event_type, path=filename)
4045
number_of_writes += 1
4146
# After receiving a write 3 times, unsubscribe from the writes channel
4247
if number_of_writes == expected_number_of_writes:

0 commit comments

Comments
 (0)