mirror of
https://github.com/markqvist/rnsh.git
synced 2025-01-05 20:30:53 -05:00
Add test suite
This commit is contained in:
parent
fcfc503184
commit
86906fd8f4
2
.github/workflows/python-package.yml
vendored
2
.github/workflows/python-package.yml
vendored
@ -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
|
||||
|
@ -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"]
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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
0
tests/__init__.py
Normal file
9
tests/test_exception.py
Normal file
9
tests/test_exception.py
Normal 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
75
tests/test_process.py
Normal 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
114
tests/test_retry.py
Normal 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
|
||||
|
Loading…
Reference in New Issue
Block a user