Skip to content

Commit 1d44d56

Browse files
authored
feat: add initial implementation (#1)
1 parent a7ca269 commit 1d44d56

File tree

8 files changed

+1144
-9
lines changed

8 files changed

+1144
-9
lines changed

.github/workflows/ci.yml

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ jobs:
4040
- "3.10"
4141
- "3.11"
4242
- "3.12"
43+
- "3.13"
4344
os:
4445
- ubuntu-latest
4546
- windows-latest

pyproject.toml

+7
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ classifiers = [
2626
]
2727

2828
dependencies = [
29+
"asyncinotify>=4.2.0",
2930
]
3031
urls."Bug Tracker" = "https://github.com/bluetooth-devices/aiousbwatcher/issues"
3132
urls.Changelog = "https://github.com/bluetooth-devices/aiousbwatcher/blob/main/CHANGELOG.md"
@@ -159,3 +160,9 @@ match = "main"
159160
[tool.semantic_release.branches.noop]
160161
match = "(?!main$)"
161162
prerelease = true
163+
164+
[tool.uv]
165+
dev-dependencies = [
166+
"pytest-asyncio>=0.25.2",
167+
"pytest-cov>=6.0.0",
168+
]

src/aiousbwatcher/__init__.py

+4
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
__version__ = "0.0.0"
2+
3+
from .impl import AIOUSBWatcher, InotifyNotAvailableError
4+
5+
__all__ = ["AIOUSBWatcher", "InotifyNotAvailableError"]

src/aiousbwatcher/impl.py

+109
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import logging
5+
from collections.abc import Generator
6+
from functools import partial
7+
from pathlib import Path
8+
from typing import Callable
9+
10+
_PATH = "/dev/bus/usb"
11+
12+
_LOGGER = logging.getLogger(__name__)
13+
14+
15+
class InotifyNotAvailableError(Exception):
16+
"""Raised when inotify is not available on the platform."""
17+
18+
19+
def _get_directories_recursive_gen(path: Path) -> Generator[Path, None, None]:
20+
if path.is_dir():
21+
yield path
22+
for child in path.iterdir():
23+
yield from _get_directories_recursive_gen(child)
24+
25+
26+
def _get_directories_recursive(path: Path) -> list[Path]:
27+
return list(_get_directories_recursive_gen(path))
28+
29+
30+
async def _async_get_directories_recursive(
31+
loop: asyncio.AbstractEventLoop, path: Path
32+
) -> list[Path]:
33+
return await loop.run_in_executor(None, _get_directories_recursive, path)
34+
35+
36+
class AIOUSBWatcher:
37+
"""A watcher for USB devices that uses asyncio."""
38+
39+
def __init__(self) -> None:
40+
self._path = Path(_PATH)
41+
self._loop = asyncio.get_running_loop()
42+
self._task: asyncio.Task[None] | None = None
43+
self._callbacks: set[Callable[[], None]] = set()
44+
45+
def async_start(self) -> Callable[[], None]:
46+
"""Start the watcher."""
47+
if self._task is not None:
48+
raise RuntimeError("Watcher already started")
49+
try:
50+
from asyncinotify import Inotify # noqa
51+
except Exception as ex:
52+
raise InotifyNotAvailableError(
53+
"Inotify not available on this platform"
54+
) from ex
55+
self._task = self._loop.create_task(self._watcher())
56+
return self._async_stop
57+
58+
def async_register_callback(
59+
self, callback: Callable[[], None]
60+
) -> Callable[[], None]:
61+
"""Register callback that will be called when a USB device is added/removed."""
62+
self._callbacks.add(callback)
63+
return partial(self._async_unregister_callback, callback)
64+
65+
def _async_stop(self) -> None:
66+
"""Stop the watcher."""
67+
assert self._task is not None # noqa
68+
self._task.cancel()
69+
self._task = None
70+
71+
async def _watcher(self) -> None:
72+
from asyncinotify import Inotify, Mask
73+
74+
mask = (
75+
Mask.CREATE
76+
| Mask.MOVED_FROM
77+
| Mask.MOVED_TO
78+
| Mask.CREATE
79+
| Mask.DELETE_SELF
80+
| Mask.IGNORED
81+
)
82+
83+
with Inotify() as inotify:
84+
for directory in await _async_get_directories_recursive(
85+
self._loop, self._path
86+
):
87+
inotify.add_watch(directory, mask)
88+
89+
async for event in inotify:
90+
# Add subdirectories to watch if a new directory is added.
91+
if Mask.CREATE in event.mask and event.path is not None:
92+
for directory in await _async_get_directories_recursive(
93+
self._loop, event.path
94+
):
95+
inotify.add_watch(directory, mask)
96+
97+
# If there is at least some overlap, assume the user wants this event.
98+
if event.mask & mask:
99+
self._async_call_callbacks()
100+
101+
def _async_unregister_callback(self, callback: Callable[[], None]) -> None:
102+
self._callbacks.remove(callback)
103+
104+
def _async_call_callbacks(self) -> None:
105+
for callback in self._callbacks:
106+
try:
107+
callback()
108+
except Exception as e:
109+
_LOGGER.exception("Error calling callback %s", callback, exc_info=e)

src/aiousbwatcher/main.py

-3
This file was deleted.

tests/test_impl.py

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import asyncio
2+
from pathlib import Path
3+
from sys import platform
4+
from unittest.mock import patch
5+
6+
import pytest
7+
8+
from aiousbwatcher import AIOUSBWatcher, InotifyNotAvailableError
9+
10+
11+
@pytest.mark.asyncio
12+
@pytest.mark.skipif(platform == "linux", reason="Inotify is available on this platform")
13+
async def test_aiousbwatcher_not_available() -> None:
14+
with pytest.raises(InotifyNotAvailableError):
15+
watcher = AIOUSBWatcher()
16+
watcher.async_start()
17+
18+
19+
@pytest.mark.asyncio
20+
@pytest.mark.skipif(
21+
platform != "linux", reason="Inotify not available on this platform"
22+
)
23+
async def test_aiousbwatcher_callbacks(tmp_path: Path) -> None:
24+
called: bool = False
25+
26+
def callback() -> None:
27+
nonlocal called
28+
called = True
29+
30+
with patch("aiousbwatcher.impl._PATH", str(tmp_path)):
31+
watcher = AIOUSBWatcher()
32+
unregister = watcher.async_register_callback(callback)
33+
stop = watcher.async_start()
34+
await asyncio.sleep(0.1)
35+
assert not called
36+
(tmp_path / "test").touch()
37+
await asyncio.sleep(0.1)
38+
assert called
39+
called = False # type: ignore[unreachable]
40+
unregister()
41+
stop()
42+
await asyncio.sleep(0.1)
43+
assert not called

tests/test_main.py

-6
This file was deleted.

0 commit comments

Comments
 (0)