|
|
@@ -0,0 +1,179 @@ |
|
|
|
# Copyright 2020 John-Mark Gurney. |
|
|
|
# All rights reserved. |
|
|
|
# |
|
|
|
# Redistribution and use in source and binary forms, with or without |
|
|
|
# modification, are permitted provided that the following conditions |
|
|
|
# are met: |
|
|
|
# 1. Redistributions of source code must retain the above copyright |
|
|
|
# notice, this list of conditions and the following disclaimer. |
|
|
|
# 2. Redistributions in binary form must reproduce the above copyright |
|
|
|
# notice, this list of conditions and the following disclaimer in the |
|
|
|
# documentation and/or other materials provided with the distribution. |
|
|
|
# |
|
|
|
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND |
|
|
|
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE |
|
|
|
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE |
|
|
|
# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE |
|
|
|
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL |
|
|
|
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS |
|
|
|
# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) |
|
|
|
# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT |
|
|
|
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY |
|
|
|
# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF |
|
|
|
# SUCH DAMAGE. |
|
|
|
# |
|
|
|
|
|
|
|
import asyncio |
|
|
|
import contextlib |
|
|
|
import itertools |
|
|
|
import os |
|
|
|
import select |
|
|
|
import shutil |
|
|
|
import tempfile |
|
|
|
import unittest |
|
|
|
|
|
|
|
# XXX - this matches on both FreeBSD and Darwin |
|
|
|
KQ_EV_RECEIPT = 0x40 |
|
|
|
|
|
|
|
class AsyncKqueue(object): |
|
|
|
def __init__(self): |
|
|
|
self._kq = select.kqueue() |
|
|
|
self._idgen = itertools.count() |
|
|
|
self._events = {} |
|
|
|
|
|
|
|
loop = asyncio.get_event_loop() |
|
|
|
loop.add_reader(self._kq, self.handleread) |
|
|
|
|
|
|
|
def handleread(self): |
|
|
|
r = self._kq.control([ ], 10, 0) |
|
|
|
for i in r: |
|
|
|
event, kevent = self._events[i.udata] |
|
|
|
|
|
|
|
# unblock anyone waiting |
|
|
|
event.set() |
|
|
|
event.clear() |
|
|
|
|
|
|
|
def isempty(self): |
|
|
|
return len(self._events) == 0 |
|
|
|
|
|
|
|
def addevent(self, fno, filter, fflags=0): |
|
|
|
eid = next(self._idgen) |
|
|
|
event = asyncio.Event() |
|
|
|
kevent = select.kevent(fno, filter, flags=select.KQ_EV_ADD|select.KQ_EV_ENABLE|select.KQ_EV_CLEAR|KQ_EV_RECEIPT, udata=eid, fflags=fflags) |
|
|
|
|
|
|
|
r = self._kq.control([ kevent ], 1, 0)[0] |
|
|
|
if r.flags != select.KQ_EV_ERROR or r.data != 0: |
|
|
|
raise RuntimeError('unable to add event') |
|
|
|
|
|
|
|
self._events[eid] = (event, kevent) |
|
|
|
|
|
|
|
return eid, event.wait |
|
|
|
|
|
|
|
def removeevent(self, id): |
|
|
|
event, kevent = self._events[id] |
|
|
|
|
|
|
|
kevent.flags=select.KQ_EV_DELETE|KQ_EV_RECEIPT |
|
|
|
|
|
|
|
r = self._kq.control([ kevent ], 1, 0)[0] |
|
|
|
if r.flags != select.KQ_EV_ERROR or r.data != 0: |
|
|
|
raise RuntimeError('unable to remove event') |
|
|
|
|
|
|
|
del self._events[id] |
|
|
|
|
|
|
|
@contextlib.asynccontextmanager |
|
|
|
async def watch_file(self, fp): |
|
|
|
try: |
|
|
|
id, waitfun = self.addevent(fp, select.KQ_FILTER_VNODE, select.KQ_NOTE_EXTEND|select.KQ_NOTE_WRITE) |
|
|
|
yield waitfun |
|
|
|
finally: |
|
|
|
self.removeevent(id) |
|
|
|
|
|
|
|
_globalkq = AsyncKqueue() |
|
|
|
|
|
|
|
watch_file = _globalkq.watch_file |
|
|
|
|
|
|
|
# https://stackoverflow.com/questions/23033939/how-to-test-python-3-4-asyncio-code |
|
|
|
# Slightly modified to timeout and to print trace back when canceled. |
|
|
|
# This makes it easier to figure out what "froze". |
|
|
|
def async_test(f): |
|
|
|
def wrapper(*args, **kwargs): |
|
|
|
async def tbcapture(): |
|
|
|
try: |
|
|
|
return await f(*args, **kwargs) |
|
|
|
except asyncio.CancelledError as e: # pragma: no cover |
|
|
|
# if we are going to be cancelled, print out a tb |
|
|
|
import traceback |
|
|
|
traceback.print_exc() |
|
|
|
raise |
|
|
|
|
|
|
|
loop = asyncio.get_event_loop() |
|
|
|
|
|
|
|
# timeout after 4 seconds |
|
|
|
loop.run_until_complete(asyncio.wait_for(tbcapture(), 4)) |
|
|
|
|
|
|
|
return wrapper |
|
|
|
|
|
|
|
class Tests(unittest.TestCase): |
|
|
|
def setUp(self): |
|
|
|
# setup temporary directory |
|
|
|
d = os.path.realpath(tempfile.mkdtemp()) |
|
|
|
self.basetempdir = d |
|
|
|
self.tempdir = os.path.join(d, 'subdir') |
|
|
|
os.mkdir(self.tempdir) |
|
|
|
|
|
|
|
os.chdir(self.tempdir) |
|
|
|
|
|
|
|
def tearDown(self): |
|
|
|
#print('td:', time.time()) |
|
|
|
shutil.rmtree(self.basetempdir) |
|
|
|
self.tempdir = None |
|
|
|
|
|
|
|
@async_test |
|
|
|
async def test_filemod(self): |
|
|
|
loop = asyncio.get_event_loop() |
|
|
|
|
|
|
|
with open('samplefile.txt', 'w+') as fp, open('samplefile.txt', 'r') as rfp: |
|
|
|
def fpwrflush(data): |
|
|
|
fp.write(data) |
|
|
|
fp.flush() |
|
|
|
|
|
|
|
# schedule some writes for later |
|
|
|
loop.call_later(.01, fpwrflush, 'something') |
|
|
|
loop.call_later(.03, fpwrflush, 'end') |
|
|
|
|
|
|
|
res = [] |
|
|
|
async with watch_file(rfp) as wf: |
|
|
|
while True: |
|
|
|
# read some data and record it |
|
|
|
data = rfp.read() |
|
|
|
res.append(data) |
|
|
|
|
|
|
|
# exit if we're at the end |
|
|
|
if data == 'end': |
|
|
|
break |
|
|
|
|
|
|
|
# wait for a modification |
|
|
|
await wf() |
|
|
|
|
|
|
|
# make sure we got the writes batched properly |
|
|
|
self.assertEqual(res, [ '', 'something', 'end' ]) |
|
|
|
|
|
|
|
# make sure the event got removed |
|
|
|
self.assertTrue(_globalkq.isempty()) |
|
|
|
|
|
|
|
def test_errors(self): |
|
|
|
eid = 100 |
|
|
|
kevent = select.kevent(100, select.KQ_FILTER_VNODE, flags=select.KQ_EV_ADD|select.KQ_EV_ENABLE|select.KQ_EV_CLEAR|KQ_EV_RECEIPT, udata=eid) |
|
|
|
_globalkq._events[eid] = (None, kevent) |
|
|
|
|
|
|
|
try: |
|
|
|
# make sure that when removing an invalid event, we get an error |
|
|
|
self.assertRaises(RuntimeError, _globalkq.removeevent, eid) |
|
|
|
finally: |
|
|
|
del _globalkq._events[eid] |
|
|
|
|
|
|
|
# that an invalid event raises an exception |
|
|
|
self.assertRaises(RuntimeError, _globalkq.addevent, 100, select.KQ_FILTER_VNODE, select.KQ_NOTE_EXTEND|select.KQ_NOTE_WRITE) |
|
|
|
|
|
|
|
# make sure their are no pending events |
|
|
|
self.assertTrue(_globalkq.isempty()) |