From 86906fd8f44789312bf097fae74d002f18f4a5d1 Mon Sep 17 00:00:00 2001 From: Aaron Heise Date: Sat, 11 Feb 2023 11:09:22 -0600 Subject: [PATCH] Add test suite --- .github/workflows/python-package.yml | 2 +- pyproject.toml | 1 + rnsh/exception.py | 12 +-- rnsh/process.py | 13 +-- rnsh/retry.py | 17 +++- tests/__init__.py | 0 tests/test_exception.py | 9 +++ tests/test_process.py | 75 ++++++++++++++++++ tests/test_retry.py | 114 +++++++++++++++++++++++++++ 9 files changed, 225 insertions(+), 18 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_exception.py create mode 100644 tests/test_process.py create mode 100644 tests/test_retry.py diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 1b4c1aa..fb09d51 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 2506742..ec5c997 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/rnsh/exception.py b/rnsh/exception.py index e7d8043..6f198c0 100644 --- a/rnsh/exception.py +++ b/rnsh/exception.py @@ -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) diff --git a/rnsh/process.py b/rnsh/process.py index a7097d3..1544f45 100644 --- a/rnsh/process.py +++ b/rnsh/process.py @@ -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() diff --git a/rnsh/retry.py b/rnsh/retry.py index 4b8e738..dba8141 100644 --- a/rnsh/retry.py +++ b/rnsh/retry.py @@ -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) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_exception.py b/tests/test_exception.py new file mode 100644 index 0000000..32a6b09 --- /dev/null +++ b/tests/test_exception.py @@ -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() \ No newline at end of file diff --git a/tests/test_process.py b/tests/test_process.py new file mode 100644 index 0000000..413b1dd --- /dev/null +++ b/tests/test_process.py @@ -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 diff --git a/tests/test_retry.py b/tests/test_retry.py new file mode 100644 index 0000000..fd9fe9c --- /dev/null +++ b/tests/test_retry.py @@ -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 +