Add test suite

This commit is contained in:
Aaron Heise 2023-02-11 11:09:22 -06:00
parent fcfc503184
commit 86906fd8f4
9 changed files with 225 additions and 18 deletions

View File

@ -39,4 +39,4 @@ jobs:
# run: poetry run black . --check
- name: Test with pytest
run: poetry run pytest --cov . -n 2
run: poetry run pytest tests

View File

@ -20,6 +20,7 @@ rnsh = 'rnsh.rnsh:rnsh_cli'
pytest = "^7.2.1"
setuptools = "^67.2.0"
black = "^23.1.0"
pytest-asyncio = "^0.20.3"
[build-system]
requires = ["poetry-core"]

View File

@ -1,3 +1,4 @@
import contextlib
from contextlib import AbstractContextManager
@ -22,13 +23,4 @@ class permit(AbstractContextManager):
pass
def __exit__(self, exctype, excinst, exctb):
# Unlike isinstance and issubclass, CPython exception handling
# currently only looks at the concrete type hierarchy (ignoring
# the instance and subclass checking hooks). While Guido considers
# that a bug rather than a feature, it's a fairly hard one to fix
# due to various internal implementation details. suppress provides
# the simpler issubclass based semantics, rather than trying to
# exactly reproduce the limitations of the CPython interpreter.
#
# See http://bugs.python.org/issue12029 for more details
return exctype is not None and issubclass(exctype, self._exceptions)
return exctype is not None and not issubclass(exctype, self._exceptions)

View File

@ -42,6 +42,8 @@ import rnsh.exception as exception
module_logger = __logging.getLogger(__name__)
CTRL_C = "\x03".encode("utf-8")
CTRL_D = "\x04".encode("utf-8")
def tty_add_reader_callback(fd: int, callback: callable, loop: asyncio.AbstractEventLoop = None):
"""
@ -336,14 +338,13 @@ class CallbackSubprocess:
if self._pid == 0:
try:
# this may not be strictly necessary, but there was
# is occasionally some funny business that goes on
# with networking. Anecdotally this fixed it, but
# more testing is needed as it might be a coincidence.
# This may not be strictly necessary, but there is
# occasionally some funny business that goes on with
# networking after the fork. Anecdotally this fixed
# it, but more testing is needed as it might be a
# coincidence.
p = psutil.Process()
for c in p.connections(kind='all'):
if c == sys.stdin.fileno() or c == sys.stdout.fileno() or c == sys.stderr.fileno():
continue
with exception.permit(SystemExit):
os.close(c.fd)
os.setpgrp()

View File

@ -62,7 +62,7 @@ class RetryStatus:
self.timeout_callback(self.tag, self.tries)
def retry(self):
self.tries += 1
self.tries = self.tries + 1
self.try_time = time.time()
self.retry_callback(self.tag, self.tries)
@ -79,6 +79,9 @@ class RetryThread:
self._thread = threading.Thread(name=name, target=self._thread_run)
self._thread.start()
def is_alive(self):
return self._thread.is_alive()
def close(self, loop: asyncio.AbstractEventLoop = None) -> asyncio.Future:
self._log.debug("stopping timer thread")
if loop is None:
@ -89,6 +92,18 @@ class RetryThread:
self._finished = loop.create_future()
return self._finished
def wait(self, timeout: float = None):
if timeout:
timeout = timeout + time.time()
while timeout is None or time.time() < timeout:
with self._lock:
task_count = len(self._statuses)
if task_count == 0:
return
time.sleep(0.1)
def _thread_run(self):
while self._run and self._finished is None:
time.sleep(self._loop_period)

0
tests/__init__.py Normal file
View File

9
tests/test_exception.py Normal file
View File

@ -0,0 +1,9 @@
import pytest
import rnsh.exception as exception
def test_permit():
with pytest.raises(SystemExit):
with exception.permit(SystemExit):
raise Exception("Should not bubble")
with exception.permit(SystemExit):
raise SystemExit()

75
tests/test_process.py Normal file
View File

@ -0,0 +1,75 @@
import uuid
import time
from types import TracebackType
from typing import Type
import pytest
import rnsh.process
import contextlib
import asyncio
import logging
import os
import threading
logging.getLogger().setLevel(logging.DEBUG)
class State(contextlib.AbstractContextManager):
def __init__(self, argv: [str], loop: asyncio.AbstractEventLoop, env: dict = None):
self.process: rnsh.process.CallbackSubprocess
self.loop = loop
self.env = env or os.environ.copy()
self.argv = argv
self._lock = threading.RLock()
self._stdout = bytearray()
self.return_code: int = None
self.process = rnsh.process.CallbackSubprocess(argv=self.argv,
env=self.env,
loop=self.loop,
stdout_callback=self._stdout_cb,
terminated_callback=self._terminated_cb)
def _stdout_cb(self, data):
with self._lock:
self._stdout.extend(data)
def read(self):
with self._lock:
data = self._stdout.copy()
self._stdout.clear()
return data
def _terminated_cb(self, rc):
self.return_code = rc
def start(self):
self.process.start()
def cleanup(self):
if self.process and self.process.running:
self.process.terminate(kill_delay=0.1)
def __exit__(self, __exc_type: Type[BaseException], __exc_value: BaseException,
__traceback: TracebackType) -> bool:
self.cleanup()
return False
@pytest.mark.asyncio
async def test_echo():
"""
Echoing some text through cat.
"""
loop = asyncio.get_running_loop()
with State(argv=["/bin/cat"],
loop=loop) as state:
state.start()
assert state.process is not None
assert state.process.running
message = "test\n"
state.process.write(message.encode("utf-8"))
await asyncio.sleep(0.1)
data = state.read()
state.process.write(rnsh.process.CTRL_D)
await asyncio.sleep(0.1)
assert len(data) > 0
decoded = data.decode("utf-8")
assert decoded == message.replace("\n", "\r\n") * 2
assert not state.process.running

114
tests/test_retry.py Normal file
View File

@ -0,0 +1,114 @@
import uuid
import time
from types import TracebackType
from typing import Type
import rnsh.retry
from contextlib import AbstractContextManager
import logging
logging.getLogger().setLevel(logging.DEBUG)
class State(AbstractContextManager):
def __init__(self, delay: float):
self.delay = delay
self.retry_thread = rnsh.retry.RetryThread(self.delay / 10.0)
self.tries = 0
self.callbacks = 0
self.timed_out = False
self.tag = str(uuid.uuid4())
self.got_tag = None
assert self.retry_thread.is_alive()
def cleanup(self):
self.retry_thread.wait()
assert self.tries != 0
self.retry_thread.close()
assert not self.retry_thread.is_alive()
def retry(self, tag, tries):
self.tries = tries
self.got_tag = tag
self.callbacks += 1
return self.tag
def timeout(self, tag, tries):
self.tries = tries
self.got_tag = tag
self.timed_out = True
self.callbacks += 1
def __exit__(self, __exc_type: Type[BaseException], __exc_value: BaseException,
__traceback: TracebackType) -> bool:
self.cleanup()
return False
def test_retry_timeout():
with State(0.1) as state:
state.retry_thread.begin(try_limit=3,
wait_delay=state.delay,
try_callback=state.retry,
timeout_callback=state.timeout)
assert state.tries == 1
assert state.callbacks == 1
assert state.got_tag is None
assert not state.timed_out
time.sleep(state.delay / 2.0)
time.sleep(state.delay)
assert state.tries == 2
assert state.callbacks == 2
assert state.got_tag == state.tag
assert not state.timed_out
time.sleep(state.delay)
assert state.tries == 3
assert state.callbacks == 3
assert state.got_tag == state.tag
assert not state.timed_out
# check timeout
time.sleep(state.delay)
assert state.tries == 3
assert state.callbacks == 4
assert state.got_tag == state.tag
assert state.timed_out
# check no more callbacks
time.sleep(state.delay * 3.0)
assert state.callbacks == 4
assert state.tries == 3
def test_retry_complete():
with State(0.01) as state:
state.retry_thread.begin(try_limit=3,
wait_delay=state.delay,
try_callback=state.retry,
timeout_callback=state.timeout)
assert state.tries == 1
assert state.callbacks == 1
assert state.got_tag is None
assert not state.timed_out
time.sleep(state.delay / 2.0)
time.sleep(state.delay)
assert state.tries == 2
assert state.callbacks == 2
assert state.got_tag == state.tag
assert not state.timed_out
state.retry_thread.complete(state.tag)
time.sleep(state.delay)
assert state.tries == 2
assert state.callbacks == 2
assert state.got_tag == state.tag
assert not state.timed_out
# check no more callbacks
time.sleep(state.delay * 3.0)
assert state.callbacks == 2
assert state.tries == 2