Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ lib-release: ## Build an optimized version of the .so module
@uvx maturin build -r

lint: ## Lint all Python source code without changes
@uv run ruff check $(SOURCE)
@uv run ruff format $(SOURCE) --diff
@uv run mypy --pretty $(SOURCE)
@uv run ruff check $(SOURCE) $(TESTS)
@uv run ruff format $(SOURCE) $(TESTS) --diff
@uv run mypy --pretty $(SOURCE) $(TESTS)

lint-fix: ## Lint all source code
@uv run ruff check --fix $(SOURCE)
@uv run ruff format $(SOURCE)
@uv run mypy --pretty $(SOURCE)
@uv run ruff check --fix $(SOURCE) $(TESTS)
@uv run ruff format $(SOURCE) $(TESTS)
@uv run mypy --pretty $(SOURCE) $(TESTS)

docs-serve: ## Run documentation locally
@pdm run mkdocs serve -a localhost:7756
Expand Down
14 changes: 6 additions & 8 deletions notifykit/_notifier.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from os import PathLike
import logging
from typing import Sequence, Protocol, Optional, List
from typing import Sequence, Protocol, Optional, List, Self
from notifykit._notifykit_lib import (
WatcherWrapper,
EventBatchIter,
Expand All @@ -25,14 +25,12 @@ async def watch(

async def unwatch(self, paths: Sequence[PathLike[str]]) -> None: ...

def __aiter__(self) -> "Notifier": ...

def __iter__(self) -> "Notifier": ...

def __next__(self) -> List[Event]: ...
def __aiter__(self) -> Self: ...

async def __anext__(self) -> List[Event]: ...

def stop(self) -> None: ...


class Notifier:
"""
Expand Down Expand Up @@ -64,8 +62,8 @@ async def watch(
) -> None:
await self._watcher.watch([str(path) for path in paths], recursive, ignore_permission_errors)

async def unwatch(self, paths: Sequence[str]) -> None:
await self._watcher.unwatch(list(paths))
async def unwatch(self, paths: Sequence[PathLike[str]]) -> None:
await self._watcher.unwatch([str(path) for path in paths])

def __aiter__(self) -> "Notifier":
# start/attach the async iterator from Rust; safe to do before watch()
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,6 @@ dev = [
"ruff>=0.12.10",
"mkdocs-material[imaging]>=9.6.18",
"mkdocs>=1.6.1",
"async-timeout>=5.0.1",
"pytest-asyncio>=1.2.0",
]
3 changes: 3 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
xfail_strict = true
Empty file added tests/__init__.py
Empty file.
62 changes: 62 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import asyncio
from contextlib import asynccontextmanager

import async_timeout
from typing import AsyncGenerator, Self, Any

from notifykit import NotifierT, Event


class EventCollector:
def __init__(self) -> None:
self._events: list[Event] = []
self._notifier_task: asyncio.Task[Any] | None = None

self._waiters: set[tuple[int, asyncio.Event]] = set()

@property
def events(self) -> list[Event]:
return self._events

@asynccontextmanager
async def collect(self, notifier: NotifierT) -> AsyncGenerator[Self, None]:
if self._notifier_task is not None:
raise RuntimeError("EventCollector is already running.")

self._notifier_task = asyncio.create_task(self._collect_events(notifier))

yield self

notifier.stop()

if self._notifier_task:
self._notifier_task.cancel()
try:
await self._notifier_task
except asyncio.CancelledError:
pass
self._notifier_task = None

async def wait_for_events(self, items: int, timeout: float = 2) -> None:
if items <= len(self._events):
return

waiter = asyncio.Event()
waiter_id = (items, waiter)

self._waiters.add(waiter_id)

async with async_timeout.timeout(timeout):
await waiter.wait()

self._waiters.remove(waiter_id)

async def _collect_events(self, notifier: NotifierT) -> None:
async for event in notifier:
self._events.extend(event)
self._wakeup_waiters()

def _wakeup_waiters(self) -> None:
for items, event in self._waiters:
if items <= len(self._events):
event.set()
32 changes: 32 additions & 0 deletions tests/test_events_create.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import asyncio
import tempfile
from pathlib import Path

from notifykit import Notifier, NotifierT
from tests.conftest import EventCollector


async def test__events__create_file() -> None:
files_to_create = 3
expected_events = files_to_create * 3 # each file triggers 3 events: Create, ModifyMetadata, ModifyData
tmp_dir = Path(tempfile.mkdtemp())

await asyncio.sleep(0.1) # avoid catching directory creation event

notifier: NotifierT = Notifier(debounce_ms=100, tick_ms=10, debug=False)
await notifier.watch([tmp_dir])

collector = EventCollector()

async with collector.collect(notifier) as c:
expected_paths = []

for idx in range(files_to_create):
file_path = tmp_dir / f"lorem_{idx}.txt"
file_path.write_text("new lorem ipsum")

expected_paths.append(str(file_path))

await c.wait_for_events(expected_events, timeout=3)

assert len(collector.events) == expected_events, collector.events
35 changes: 21 additions & 14 deletions tests/test_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,31 @@
from notifykit import CommonFilter, ModifyDataEvent, DataType, RenameEvent


@pytest.mark.parametrize("path,filtered", [
(Path("/home/myusr/proj/__pycache__/tmpfie"), True),
(Path("/home/myusr/proj/.git/HEAD"), True),
(Path("/home/myusr/proj/.venv/lib/python3/notifykit/testing.py"), True),
(Path("/home/myusr/proj/main.py"), False),
(Path("/home/myusr/proj/logs.txt~"), True),
])
@pytest.mark.parametrize(
"path,filtered",
[
(Path("/home/myusr/proj/__pycache__/tmpfie"), True),
(Path("/home/myusr/proj/.git/HEAD"), True),
(Path("/home/myusr/proj/.venv/lib/python3/notifykit/testing.py"), True),
(Path("/home/myusr/proj/main.py"), False),
(Path("/home/myusr/proj/logs.txt~"), True),
],
)
def test__event_filter__ignore_paths(path: Path, filtered: bool) -> None:
filter = CommonFilter()

assert filter(ModifyDataEvent(path=path, data_type=DataType.CONTENT)) == filtered
assert filter(ModifyDataEvent(path=str(path), data_type=DataType.CONTENT)) == filtered

@pytest.mark.parametrize("old_path,new_path,filtered", [
(Path("/home/myusr/proj/__pycache__/tmpfie"), Path("/home/myusr/proj/tmpfile"), False),
(Path("/home/myusr/proj/logs.txt"), Path("/home/myusr/proj/.git/logs.txt"), False),
(Path("/home/myusr/proj/__pycache__/abcdg"), Path("/home/myusr/proj/.venv/abcdg"), True),
])

@pytest.mark.parametrize(
"old_path,new_path,filtered",
[
(Path("/home/myusr/proj/__pycache__/tmpfie"), Path("/home/myusr/proj/tmpfile"), False),
(Path("/home/myusr/proj/logs.txt"), Path("/home/myusr/proj/.git/logs.txt"), False),
(Path("/home/myusr/proj/__pycache__/abcdg"), Path("/home/myusr/proj/.venv/abcdg"), True),
],
)
def test__event_filter__ignore_renames(old_path: Path, new_path: Path, filtered: bool) -> None:
filter = CommonFilter()

assert filter(RenameEvent(old_path=old_path, new_path=new_path)) == filtered
assert filter(RenameEvent(old_path=str(old_path), new_path=str(new_path))) == filtered
Loading
Loading