Skip to content

Commit

Permalink
Move nested class out of the parent class
Browse files Browse the repository at this point in the history
Removing nested classes avoids having to use hacky constructions, like
requiring the use of `from __future__ import annotations`, types as
strings and confusing the `mkdocstrings` tools when extracting and
cross-linking docs.

Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
  • Loading branch information
llucax committed Nov 7, 2023
1 parent bbb0a8c commit eb6bbbb
Show file tree
Hide file tree
Showing 4 changed files with 43 additions and 50 deletions.
23 changes: 12 additions & 11 deletions src/frequenz/channels/_select.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@
_T = TypeVar("_T")


class _EmptyResult:
"""A sentinel value to distinguish between None and empty result.
We need a sentinel because a result can also be `None`.
"""

def __repr__(self) -> str:
return "<empty>"


class Selected(Generic[_T]):
"""A result of a [`select()`][frequenz.channels.select] iteration.
Expand All @@ -31,15 +41,6 @@ class Selected(Generic[_T]):
Please see [`select()`][frequenz.channels.select] for an example.
"""

class _EmptyResult:
"""A sentinel value to distinguish between None and empty result.
We need a sentinel because a result can also be `None`.
"""

def __repr__(self) -> str:
return "<empty>"

def __init__(self, receiver: Receiver[_T]) -> None:
"""Create a new instance.
Expand All @@ -55,7 +56,7 @@ def __init__(self, receiver: Receiver[_T]) -> None:
self._recv: Receiver[_T] = receiver
"""The receiver that was selected."""

self._value: _T | Selected._EmptyResult = Selected._EmptyResult()
self._value: _T | _EmptyResult = _EmptyResult()
"""The value that was received.
If there was an exception while receiving the value, then this will be `None`.
Expand Down Expand Up @@ -86,7 +87,7 @@ def value(self) -> _T:
"""
if self._exception is not None:
raise self._exception
assert not isinstance(self._value, Selected._EmptyResult)
assert not isinstance(self._value, _EmptyResult)
return self._value

@property
Expand Down
44 changes: 21 additions & 23 deletions src/frequenz/channels/file_watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@

"""A Channel receiver for watching for new, modified or deleted files."""

from __future__ import annotations

import asyncio
import pathlib
from collections import abc
Expand All @@ -17,29 +15,31 @@
from ._receiver import Receiver, ReceiverStoppedError


class FileWatcher(Receiver["FileWatcher.Event"]):
"""A channel receiver that watches for file events."""
class EventType(Enum):
"""Available types of changes to watch for."""

CREATE = Change.added
"""A new file was created."""

class EventType(Enum):
"""Available types of changes to watch for."""
MODIFY = Change.modified
"""An existing file was modified."""

CREATE = Change.added
"""A new file was created."""
DELETE = Change.deleted
"""An existing file was deleted."""

MODIFY = Change.modified
"""An existing file was modified."""

DELETE = Change.deleted
"""An existing file was deleted."""
@dataclass(frozen=True)
class Event:
"""A file change event."""

@dataclass(frozen=True)
class Event:
"""A file change event."""
type: EventType
"""The type of change that was observed."""
path: pathlib.Path
"""The path where the change was observed."""

type: FileWatcher.EventType
"""The type of change that was observed."""
path: pathlib.Path
"""The path where the change was observed."""

class FileWatcher(Receiver[Event]):
"""A channel receiver that watches for file events."""

def __init__(
self,
Expand All @@ -53,7 +53,7 @@ def __init__(
event_types: Types of events to watch for. Defaults to watch for
all event types.
"""
self.event_types: frozenset[FileWatcher.EventType] = frozenset(event_types)
self.event_types: frozenset[EventType] = frozenset(event_types)
"""The types of events to watch for."""

self._stop_event: asyncio.Event = asyncio.Event()
Expand Down Expand Up @@ -133,9 +133,7 @@ def consume(self) -> Event:
assert self._changes, "`consume()` must be preceded by a call to `ready()`"
# Tuple of (Change, path) returned by watchfiles
change, path_str = self._changes.pop()
return FileWatcher.Event(
type=FileWatcher.EventType(change), path=pathlib.Path(path_str)
)
return Event(type=EventType(change), path=pathlib.Path(path_str))

def __str__(self) -> str:
"""Return a string representation of this receiver."""
Expand Down
12 changes: 6 additions & 6 deletions tests/test_file_watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from watchfiles import Change
from watchfiles.main import FileChange

from frequenz.channels.file_watcher import FileWatcher
from frequenz.channels.file_watcher import Event, EventType, FileWatcher


class _FakeAwatch:
Expand Down Expand Up @@ -74,14 +74,14 @@ async def test_file_watcher_receive_updates(

for change in changes:
recv_changes = await file_watcher.receive()
event_type = FileWatcher.EventType(change[0])
event_type = EventType(change[0])
path = pathlib.Path(change[1])
assert recv_changes == FileWatcher.Event(type=event_type, path=path)
assert recv_changes == Event(type=event_type, path=path)


@hypothesis.given(event_types=st.sets(st.sampled_from(FileWatcher.EventType)))
@hypothesis.given(event_types=st.sets(st.sampled_from(EventType)))
async def test_file_watcher_filter_events(
event_types: set[FileWatcher.EventType],
event_types: set[EventType],
) -> None:
"""Test the file watcher events filtering."""
good_path = "good-file"
Expand All @@ -100,7 +100,7 @@ async def test_file_watcher_filter_events(
pathlib.Path(good_path), stop_event=mock.ANY, watch_filter=filter_events
)
]
for event_type in FileWatcher.EventType:
for event_type in EventType:
assert filter_events(event_type.value, good_path) == (
event_type in event_types
)
14 changes: 4 additions & 10 deletions tests/test_file_watcher_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import pytest

from frequenz.channels import select, selected_from
from frequenz.channels.file_watcher import FileWatcher
from frequenz.channels.file_watcher import Event, EventType, FileWatcher
from frequenz.channels.timer import Timer


Expand All @@ -33,12 +33,8 @@ async def test_file_watcher(tmp_path: pathlib.Path) -> None:
if selected_from(selected, timer):
filename.write_text(f"{selected.value}")
elif selected_from(selected, file_watcher):
event_type = (
FileWatcher.EventType.CREATE
if number_of_writes == 0
else FileWatcher.EventType.MODIFY
)
assert selected.value == FileWatcher.Event(type=event_type, path=filename)
event_type = EventType.CREATE if number_of_writes == 0 else EventType.MODIFY
assert selected.value == Event(type=event_type, path=filename)
number_of_writes += 1
# After receiving a write 3 times, unsubscribe from the writes channel
if number_of_writes == expected_number_of_writes:
Expand All @@ -58,9 +54,7 @@ async def test_file_watcher_deletes(tmp_path: pathlib.Path) -> None:
tmp_path: A tmp directory to run the file watcher on. Created by pytest.
"""
filename = tmp_path / "test-file"
file_watcher = FileWatcher(
paths=[str(tmp_path)], event_types={FileWatcher.EventType.DELETE}
)
file_watcher = FileWatcher(paths=[str(tmp_path)], event_types={EventType.DELETE})
write_timer = Timer.timeout(timedelta(seconds=0.1))
deletion_timer = Timer.timeout(timedelta(seconds=0.25))

Expand Down

0 comments on commit eb6bbbb

Please sign in to comment.