diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..be7dfed --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,36 @@ +name: Lint + +on: + push + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.7'] + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + shell: bash + run: pip install -r requirements_dev.txt + + - name: Install the library + shell: bash + run: pip install . + + - name: Run mypy + shell: bash + run: mypy suby --strict + + - name: Run ruff + shell: bash + run: ruff suby diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fd1689b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,34 @@ +name: Release + +on: + push: + branches: + - main + +jobs: + pypi-publish: + name: upload release to PyPI + runs-on: ubuntu-latest + # Specifying a GitHub environment is optional, but strongly encouraged + environment: release + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + shell: bash + run: pip install -r requirements_dev.txt + + - name: Build the project + shell: bash + run: python -m build . + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/tests_and_coverage.yml b/.github/workflows/tests_and_coverage.yml new file mode 100644 index 0000000..bb84f16 --- /dev/null +++ b/.github/workflows/tests_and_coverage.yml @@ -0,0 +1,45 @@ +name: Tests + +on: + push + +jobs: + build: + + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [macos-latest, ubuntu-latest, windows-latest] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + + - name: Install the library + shell: bash + run: pip install . + + - name: Install dependencies + shell: bash + run: pip install -r requirements_dev.txt + + - name: Print all libs + shell: bash + run: pip list + + - name: Run tests and show coverage on the command line + run: coverage run --source=suby --omit="*tests*" -m pytest --cache-clear --assert=plain && coverage report -m + + - name: Upload reports to codecov + env: + CODECOV_TOKEN: ${{secrets.CODECOV_TOKEN}} + if: runner.os == 'Linux' + run: | + curl -Os https://uploader.codecov.io/latest/linux/codecov + find . -iregex "codecov.*" + chmod +x codecov + ./codecov -t ${CODECOV_TOKEN} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..32ea07b --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.DS_Store +__pycache__ +venv +.pytest_cache +build +dist +*.egg-info +test.py +.coverage +.coverage.* +.idea +.ruff_cache +.mutmut-cache +.mypy_cache +html diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..ec5b9a4 --- /dev/null +++ b/.ruff.toml @@ -0,0 +1 @@ +ignore = ['E501', 'E712'] diff --git a/README.md b/README.md index 57eee4d..c9817c0 100644 --- a/README.md +++ b/README.md @@ -1 +1,231 @@ -# subi \ No newline at end of file +![logo](https://raw.githubusercontent.com/pomponchik/suby/develop/docs/assets/logo_5.svg) + +[![Downloads](https://static.pepy.tech/badge/suby/month)](https://pepy.tech/project/suby) +[![Downloads](https://static.pepy.tech/badge/suby)](https://pepy.tech/project/suby) +[![codecov](https://codecov.io/gh/pomponchik/suby/graph/badge.svg?token=IyYI7IaSet)](https://codecov.io/gh/pomponchik/suby) +[![Lines of code](https://sloc.xyz/github/pomponchik/suby/?category=code)](https://github.com/boyter/scc/) +[![Hits-of-Code](https://hitsofcode.com/github/pomponchik/suby?branch=main)](https://hitsofcode.com/github/pomponchik/suby/view?branch=main) +[![Test-Package](https://github.com/pomponchik/suby/actions/workflows/tests_and_coverage.yml/badge.svg)](https://github.com/pomponchik/suby/actions/workflows/tests_and_coverage.yml) +[![Python versions](https://img.shields.io/pypi/pyversions/suby.svg)](https://pypi.python.org/pypi/suby) +[![PyPI version](https://badge.fury.io/py/suby.svg)](https://badge.fury.io/py/suby) +[![Checked with mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/) +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) + + +Here is a small wrapper around the [subprocesses](https://docs.python.org/3/library/subprocess.html). You can find many similar wrappers, but this particular one differs from the others in the following parameters: + +- Beautiful minimalistic call syntax. +- Ability to specify your callbacks to catch `stdout` and `stderr`. +- Support for [cancellation tokens](https://github.com/pomponchik/cantok). +- You can set timeouts for subprocesses. +- Logging of command execution. + + +## Table of contents + +- [**Quick start**](#quick-start) +- [**Run subprocess and look at the result**](#run-subprocess-and-look-at-the-result) +- [**Output**](#output) +- [**Logging**](#logging) +- [**Exceptions**](#exceptions) +- [**Working with Cancellation Tokens**](#working-with-cancellation-tokens) +- [**Timeouts**](#timeouts) + + +## Quick start + +Install it: + +```bash +pip install suby +``` + +And use: + +```python +import suby + +suby('python', '-c', 'print("hello, world!")') +# > hello, world! +``` + + +## Run subprocess and look at the result + +The `suby` function returns an object of the `SubprocessResult` class. It contains the following required fields: + +- **id** - a unique string that allows you to distinguish one result of calling the same command from another. +- **stdout** - a string containing the entire buffered output of the command being run. +- **stderr** - a string containing the entire buffered stderr of the command being run. +- **returncode** - an integer indicating the return code of the subprocess. `0` means that the process was completed successfully, the other options usually indicate something bad. +- **killed_by_token** - a boolean flag indicating whether the subprocess was killed due to [token](https://cantok.readthedocs.io/en/latest/the_pattern/) cancellation. + +The simplest example of what it might look like: + +```python +import suby + +result = suby('python', '-c', 'print("hello, world!")') +print(result) +# > SubprocessResult(id='e9f2d29acb4011ee8957320319d7541c', stdout='hello, world!\n', stderr='', returncode=0, killed_by_token=False) +``` + + +## Output + +By default, the `stdout` and `stderr` of the subprocess are forwarded to the `stdout` and `stderr` of the current process. The reading from the subprocess is continuous, and the output is every time a full line is read. For continuous reading from `stderr`, a separate thread is created in the main process, so that `stdout` and `stderr` are read independently. + +You can override the output functions for `stdout` and `stderr`. To do this, you need to pass as arguments `stdout_callback` and `stderr_callback`, respectively, some functions that accept a string as an argument. For example, you can color the output (the code example uses the [`termcolor`](https://github.com/termcolor/termcolor) library): + +```python +import suby +from termcolor import colored + +def my_new_stdout(string: str) -> None: + print(colored(string, 'red'), end='') + +suby('python', '-c', 'print("hello, world!")', stdout_callback=my_new_stdout) +# > hello, world! +# You can't see it here, but believe me, if you repeat the code at home, the output in the console will be red! +``` + +You can also completely disable the output by passing `True` as the `catch_output` parameter: + +```python +suby('python', '-c', 'print("hello, world!")', catch_output=True) +# There's nothing here. +``` + +If you specify `catch_output=True`, and at the same time redefine your functions for output, your functions will not be called either. In addition, `suby` always returns [the result](#run-subprocess-and-look-at-the-result) of executing the command, containing the full output. The `catch_output` argument can stop exactly the output, but it does not prevent the collection and buffering of the output. + + +## Logging + +By default, `suby` does not log command execution. However, you can pass a logger object to the function, and in this case logs will be recorded at the start of the command execution and at the end of the execution: + +```python +import logging +import suby + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[ + logging.StreamHandler(), + ] +) + +suby('python', '-c', 'pass', logger=logging.getLogger('logger_name')) +# > 2024-02-22 02:15:08,155 [INFO] The beginning of the execution of the command "python -c pass". +# > 2024-02-22 02:15:08,190 [INFO] The command "python -c pass" has been successfully executed. +``` + +The message about the start of the command execution is always done with the `INFO` [level](https://docs.python.org/3.8/library/logging.html#logging-levels). If the command is completed successfully, the end message will also be with the `INFO` level. And if not - `ERROR`: + +```python +suby('python', '-c', 'raise ValueError', logger=logging.getLogger('logger_name'), catch_exceptions=True, catch_output=True) +# > 2024-02-22 02:20:25,549 [INFO] The beginning of the execution of the command "python -c "raise ValueError"". +# > 2024-02-22 02:20:25,590 [ERROR] Error when executing the command "python -c "raise ValueError"". +``` + +If you don't need these details, just don't pass the logger object. + +If you still prefer logging, you can use any object that implements the [logger protocol](https://github.com/pomponchik/emptylog?tab=readme-ov-file#universal-logger-protocol) from the [`emptylog`](https://github.com/pomponchik/emptylog) library, including ones from third-party libraries. + + +## Exceptions + +By default, `suby` raises exceptions in three cases: + +1. If the command you are calling ended with a return code not equal to `0`. In this case, you will see an exception `suby.RunningCommandError`: + +```python +import suby + +try: + suby('python', '-c', '1/0') +except suby.RunningCommandError as e: + print(e) + # > Error when executing the command "python -c 1/0". +``` + +2. If you passed a [cancellation token](https://cantok.readthedocs.io/en/latest/the_pattern/) when calling the command, and the token was canceled, an exception will be raised [corresponding to the type](https://cantok.readthedocs.io/en/latest/what_are_tokens/exceptions/) of canceled token. [This part of the functionality](#working-with-cancellation-tokens) is integrated with the [cantok](https://cantok.readthedocs.io/en/latest/) library, so we recommend that you familiarize yourself with it beforehand. + +3. You have set a [timeout](#timeouts) for the operation and it has expired. + +You can prevent `suby` from raising any exceptions. To do this, set the `catch_exceptions` parameter to `True`: + +```python +result = suby('python', '-c', 'import time; time.sleep(10_000)', timeout=1, catch_exceptions=True) +print(result) +# > SubprocessResult(id='c9125b90d03111ee9660320319d7541c', stdout='', stderr='', returncode=-9, killed_by_token=True) +``` + +Keep in mind that the full result of the subprocess call can also be found through the `result` attribute of any raised exception: + +```python +try: + suby('python', '-c', 'import time; time.sleep(10_000)', timeout=1) +except suby.TimeoutCancellationError as e: + print(e.result) + # > SubprocessResult(id='a80dc26cd03211eea347320319d7541c', stdout='', stderr='', returncode=-9, killed_by_token=True) +``` + + +## Working with Cancellation Tokens + +`suby` is fully compatible with the [cancellation token pattern](https://cantok.readthedocs.io/en/latest/the_pattern/) and supports any token objects from the [`cantok`](https://github.com/pomponchik/cantok) library. + +The essence of the pattern is that you can pass an object to `suby`, from which it can find out whether the operation still needs to be continued or not. If not, it kills the subprocess. This pattern can be especially useful in the case of commands that are executed for a long time or for an unpredictably long time. When the result becomes unnecessary, there is no point in sitting and waiting for the command to work out. + +So, you can pass your cancellation tokens to `suby`. By default, canceling a token causes an exception to be raised: + +```python +from random import randint +import suby +from cantok import ConditionToken + +token = ConditionToken(lambda: randint(1, 1000) == 7) # This token will be cancelled when a random unlikely event occurs. +suby('python', '-c', 'import time; time.sleep(10_000)', token=token) +# > cantok.errors.ConditionCancellationError: The cancellation condition was satisfied. +``` + +However, if you pass the `catch_exceptions=True` argument, the exception [will not be raised](#exceptions). Instead, you will get the [usual result](#run-subprocess-and-look-at-the-result) of calling `suby` with the `killed_by_token=True` flag: + +```python +token = ConditionToken(lambda: randint(1, 1000) == 7) +print(suby('python', '-c', 'import time; time.sleep(10_000)', token=token, catch_exceptions=True)) +# > SubprocessResult(id='e92ccd54d24b11ee8376320319d7541c', stdout='', stderr='', returncode=-9, killed_by_token=True) +``` + +"Under the hood" a separate thread is created to track the status of the token. When the token is canceled, the thread kills the subprocess. + +## Timeouts + +You can set a timeout for `suby`. It must be an integer greater than zero, which indicates the number of seconds that the sub process can continue to run. If the timeout expires before the subprocess completes, an exception will be raised: + +```python +import suby + +suby('python', '-c', 'import time; time.sleep(10_000)', timeout=1) +# > cantok.errors.TimeoutCancellationError: The timeout of 1 seconds has expired. +``` + +To count the timeout, "under the hood" `suby` uses [`TimeoutToken`](https://cantok.readthedocs.io/en/latest/types_of_tokens/TimeoutToken/) from the [`cantok`](https://github.com/pomponchik/cantok) library. + +The exception corresponding to this token was be reimported to `suby`: + +```python +try: + suby('python', '-c', 'import time; time.sleep(10_000)', timeout=1) +except suby.TimeoutCancellationError as e: # As you can see, TimeoutCancellationError is available in the suby module. + print(e) + # > The timeout of 1 seconds has expired. +``` + +Just as with [regular cancellation tokens](#working-with-cancellation-tokens), you can prevent exceptions from being raised using the `catch_exceptions=True` argument: + +```python +print(suby('python', '-c', 'import time; time.sleep(10_000)', timeout=1, catch_exceptions=True)) +# > SubprocessResult(id='ea88c518d25011eeb25e320319d7541c', stdout='', stderr='', returncode=-9, killed_by_token=True) +``` diff --git a/docs/assets/logo_1.svg b/docs/assets/logo_1.svg new file mode 100644 index 0000000..d41ba5a --- /dev/null +++ b/docs/assets/logo_1.svg @@ -0,0 +1,661 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + S BY + + diff --git a/docs/assets/logo_2.svg b/docs/assets/logo_2.svg new file mode 100644 index 0000000..6f5c608 --- /dev/null +++ b/docs/assets/logo_2.svg @@ -0,0 +1,192 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SUBY + + + diff --git a/docs/assets/logo_3.svg b/docs/assets/logo_3.svg new file mode 100644 index 0000000..6eced65 --- /dev/null +++ b/docs/assets/logo_3.svg @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SUBY + + + diff --git a/docs/assets/logo_4.svg b/docs/assets/logo_4.svg new file mode 100644 index 0000000..9810abd --- /dev/null +++ b/docs/assets/logo_4.svg @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SUBY + + + diff --git a/docs/assets/logo_5.svg b/docs/assets/logo_5.svg new file mode 100644 index 0000000..b4c9385 --- /dev/null +++ b/docs/assets/logo_5.svg @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..63ad4b1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["setuptools==68.0.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "suby" +version = "0.0.1" +authors = [ + { name="Evgeniy Blinov", email="zheni-b@yandex.ru" }, +] +description = 'Slightly simplified subprocesses' +readme = "README.md" +requires-python = ">=3.7" +dependencies = [ + 'emptylog>=0.0.3', + 'cantok>=0.0.18', +] +classifiers = [ + "Operating System :: OS Independent", + 'Operating System :: MacOS :: MacOS X', + 'Operating System :: Microsoft :: Windows', + 'Operating System :: POSIX', + 'Operating System :: POSIX :: Linux', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'License :: OSI Approved :: MIT License', + 'Intended Audience :: Developers', + 'Topic :: Software Development :: Libraries', +] + +[tool.setuptools.package-data] +"suby" = ["py.typed"] + +[tool.mutmut] +paths_to_mutate="suby" +runner="pytest" + +[project.urls] +'Source' = 'https://github.com/pomponchik/suby' +'Tracker' = 'https://github.com/pomponchik/suby/issues' diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..6b880b0 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,8 @@ +pytest==7.4.2 +coverage==7.2.7 +build==0.9.0 +twine==4.0.2 +mypy==1.4.1 +ruff==0.0.290 +mutmut==2.4.4 +emptylog>=0.0.3 diff --git a/suby/__init__.py b/suby/__init__.py new file mode 100644 index 0000000..b11fd3f --- /dev/null +++ b/suby/__init__.py @@ -0,0 +1,9 @@ +import sys + +from suby.proxy_module import ProxyModule as ProxyModule +from suby.errors import RunningCommandError as RunningCommandError # noqa: F401 + +from cantok import TimeoutCancellationError as TimeoutCancellationError # noqa: F401 + + +sys.modules[__name__].__class__ = ProxyModule diff --git a/suby/callbacks.py b/suby/callbacks.py new file mode 100644 index 0000000..9014ab0 --- /dev/null +++ b/suby/callbacks.py @@ -0,0 +1,10 @@ +import sys + + +def stderr_with_flush(string: str) -> None: + sys.stderr.write(string) + sys.stderr.flush() + +def stdout_with_flush(string: str) -> None: + print(string, end='') + sys.stdout.flush() diff --git a/suby/errors.py b/suby/errors.py new file mode 100644 index 0000000..f501c62 --- /dev/null +++ b/suby/errors.py @@ -0,0 +1,7 @@ +from suby.subprocess_result import SubprocessResult + + +class RunningCommandError(Exception): + def __init__(self, message: str, subprocess_result: SubprocessResult) -> None: + self.result = subprocess_result + super().__init__(message) diff --git a/suby/proxy_module.py b/suby/proxy_module.py new file mode 100644 index 0000000..9237fbb --- /dev/null +++ b/suby/proxy_module.py @@ -0,0 +1,115 @@ +import sys +from time import sleep +from threading import Thread +from subprocess import Popen, PIPE +from typing import List, Callable, Union, Optional, Any + +from emptylog import EmptyLogger, LoggerProtocol +from cantok import AbstractToken, TimeoutToken, CancellationError + +from suby.errors import RunningCommandError +from suby.subprocess_result import SubprocessResult +from suby.callbacks import stdout_with_flush, stderr_with_flush + + +class ProxyModule(sys.modules[__name__].__class__): # type: ignore[misc] + def __call__( + self, + *arguments: str, + catch_output: bool = False, + catch_exceptions: bool = False, + logger: LoggerProtocol = EmptyLogger(), + stdout_callback: Callable[[str], Any] = stdout_with_flush, + stderr_callback: Callable[[str], Any] = stderr_with_flush, + timeout: Optional[Union[int, float]] = None, + token: Optional[AbstractToken] = None, + ) -> SubprocessResult: + """ + About reading from strout and stderr: https://stackoverflow.com/a/28319191/14522393 + """ + if timeout is not None and token is None: + token = TimeoutToken(timeout) + elif timeout is not None: + token += TimeoutToken(timeout) # type: ignore[operator] + + arguments_string_representation = ' '.join([argument if ' ' not in argument else f'"{argument}"' for argument in arguments]) + + stdout_buffer: List[str] = [] + stderr_buffer: List[str] = [] + result = SubprocessResult() + + logger.info(f'The beginning of the execution of the command "{arguments_string_representation}".') + + with Popen(list(arguments), stdout=PIPE, stderr=PIPE, bufsize=1, universal_newlines=True) as process: + stderr_reading_thread = self.run_stderr_thread(process, stderr_buffer, result, catch_output, stderr_callback) + if token is not None: + killing_thread = self.run_killing_thread(process, token, result) + + for line in process.stdout: # type: ignore[union-attr] + stdout_buffer.append(line) + if not catch_output: + stdout_callback(line) + + stderr_reading_thread.join() + if token is not None: + killing_thread.join() + + self.fill_result(stdout_buffer, stderr_buffer, process.returncode, result) + + if process.returncode != 0: + if not catch_exceptions: + if result.killed_by_token: + logger.error(f'The execution of the "{arguments_string_representation}" command was canceled using a cancellation token.') + try: + token.check() # type: ignore[union-attr] + except CancellationError as e: + e.result = result # type: ignore[attr-defined] + raise e + else: + message = f'Error when executing the command "{arguments_string_representation}".' + logger.error(message) + raise RunningCommandError(message, result) + else: + if result.killed_by_token: + logger.error(f'The execution of the "{arguments_string_representation}" command was canceled using a cancellation token.') + else: + logger.error(f'Error when executing the command "{arguments_string_representation}".') + + else: + logger.info(f'The command "{arguments_string_representation}" has been successfully executed.') + + return result + + def run_killing_thread(self, process: Popen, token: AbstractToken, result: SubprocessResult) -> Thread: # type: ignore[type-arg] + thread = Thread(target=self.killing_loop, args=(process, token, result)) + thread.start() + return thread + + def run_stderr_thread(self, process: Popen, stderr_buffer: List[str], result: SubprocessResult, catch_output: bool, stderr_callback: Callable[[str], Any]) -> Thread: # type: ignore[type-arg] + thread = Thread(target=self.read_stderr, args=(process, stderr_buffer, result, catch_output, stderr_callback)) + thread.start() + return thread + + @staticmethod + def killing_loop(process: Popen, token: AbstractToken, result: SubprocessResult) -> None: # type: ignore[type-arg] + while True: + if not token: + process.kill() + result.killed_by_token = True + break + if process.poll() is not None: + break + sleep(0.0001) + + @staticmethod + def read_stderr(process: Popen, stderr_buffer: List[str], result: SubprocessResult, catch_output: bool, stderr_callback: Callable[[str], Any]) -> None: # type: ignore[type-arg] + for line in process.stderr: # type: ignore[union-attr] + stderr_buffer.append(line) + if not catch_output: + stderr_callback(line) + + @staticmethod + def fill_result(stdout_buffer: List[str], stderr_buffer: List[str], returncode: int, result: SubprocessResult) -> None: + result.stdout = ''.join(stdout_buffer) + result.stderr = ''.join(stderr_buffer) + result.returncode = returncode diff --git a/suby/py.typed b/suby/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/suby/subprocess_result.py b/suby/subprocess_result.py new file mode 100644 index 0000000..9dd6185 --- /dev/null +++ b/suby/subprocess_result.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass, field +from typing import Optional +from uuid import uuid1 + + +@dataclass +class SubprocessResult: + id: str = field(default_factory=lambda: str(uuid1()).replace('-', '')) + stdout: Optional[str] = None + stderr: Optional[str] = None + returncode: Optional[int] = None + killed_by_token: bool = False diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_callbacks.py b/tests/test_callbacks.py new file mode 100644 index 0000000..467b6d6 --- /dev/null +++ b/tests/test_callbacks.py @@ -0,0 +1,26 @@ +from io import StringIO +from contextlib import redirect_stdout, redirect_stderr + +from suby.callbacks import stdout_with_flush, stderr_with_flush + + +def test_output_to_stdout(): + stderr_buffer = StringIO() + stdout_buffer = StringIO() + + with redirect_stdout(stdout_buffer), redirect_stderr(stderr_buffer): + stdout_with_flush('kek') + + assert stderr_buffer.getvalue() == '' + assert stdout_buffer.getvalue() == 'kek' + + +def test_output_to_stderr(): + stderr_buffer = StringIO() + stdout_buffer = StringIO() + + with redirect_stdout(stdout_buffer), redirect_stderr(stderr_buffer): + stderr_with_flush('kek') + + assert stderr_buffer.getvalue() == 'kek' + assert stdout_buffer.getvalue() == '' diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..3b4d726 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,11 @@ +from suby.errors import RunningCommandError +from suby.subprocess_result import SubprocessResult + + +def test_init_exception_and_raise(): + result = SubprocessResult() + try: + raise RunningCommandError('kek', result) + except RunningCommandError as e: + assert str(e) == 'kek' + assert e.result is result diff --git a/tests/test_proxy_module.py b/tests/test_proxy_module.py new file mode 100644 index 0000000..34a7431 --- /dev/null +++ b/tests/test_proxy_module.py @@ -0,0 +1,310 @@ +import re +import sys +from time import perf_counter +from io import StringIO +from contextlib import redirect_stdout, redirect_stderr + +import pytest +from cantok import TimeoutCancellationError, ConditionCancellationError, ConditionToken, SimpleToken +from emptylog import MemoryLogger + +import suby +from suby import RunningCommandError + + +def test_normal_way(): + result = suby(sys.executable, '-c', 'print("kek")') + + assert result.stdout == 'kek\n' + assert result.stderr == '' + assert result.returncode == 0 + + +def test_normal_way_with_simple_token(): + result = suby(sys.executable, '-c', 'print("kek")', token=SimpleToken()) + + assert result.stdout == 'kek\n' + assert result.stderr == '' + assert result.returncode == 0 + + +def test_stderr_catching(): + result = suby(sys.executable, '-c', 'import sys; sys.stderr.write("kek")') + + assert result.stdout == '' + assert result.stderr == 'kek' + assert result.returncode == 0 + + +def test_catch_exception(): + result = suby(sys.executable, '-c', 'raise ValueError', catch_exceptions=True) + + assert 'ValueError' in result.stderr + assert result.returncode != 0 + + +def test_timeout(): + sleep_time = 100000 + timeout = 0.001 + + start_time = perf_counter() + result = suby(sys.executable, '-c', f'import time; time.sleep({sleep_time})', timeout=timeout, catch_exceptions=True) + end_time = perf_counter() + + assert result.returncode != 0 + assert result.stdout == '' + assert result.stderr == '' + + assert (end_time - start_time) < sleep_time + assert (end_time - start_time) >= timeout + + +def test_timeout_without_catching_exception(): + sleep_time = 100000 + timeout = 0.001 + + with pytest.raises(TimeoutCancellationError): + suby(sys.executable, '-c', f'import time; time.sleep({sleep_time})', timeout=timeout) + + start_time = perf_counter() + try: + result = suby(sys.executable, '-c', f'import time; time.sleep({sleep_time})', timeout=timeout) + except TimeoutCancellationError as e: + assert e.result.stdout == '' + assert e.result.stderr == '' + assert e.result.returncode != 0 + end_time = perf_counter() + + assert (end_time - start_time) < sleep_time + assert (end_time - start_time) >= timeout + + +def test_exception_in_subprocess_without_catching(): + with pytest.raises(RunningCommandError, match=re.escape(f'Error when executing the command "{sys.executable} -c "raise ValueError"".')): + suby(sys.executable, '-c', 'raise ValueError') + + try: + suby(sys.executable, '-c', 'raise ValueError') + except RunningCommandError as e: + assert e.result.stdout == '' + assert 'ValueError' in e.result.stderr + assert e.result.returncode != 0 + + +def test_not_catching_output(): + stderr_buffer = StringIO() + stdout_buffer = StringIO() + + with redirect_stdout(stdout_buffer), redirect_stderr(stderr_buffer): + result = suby(sys.executable, '-c', 'print("kek1", end=""); import sys; sys.stderr.write("kek2")', catch_output=False) + + stderr = stderr_buffer.getvalue() + stdout = stdout_buffer.getvalue() + + assert result.returncode == 0 + assert stderr == 'kek2' + assert stdout == 'kek1' + + +def test_catching_output(): + stderr_buffer = StringIO() + stdout_buffer = StringIO() + + with redirect_stdout(stdout_buffer), redirect_stderr(stderr_buffer): + result = suby(sys.executable, '-c', 'print("kek1", end=""); import sys; sys.stderr.write("kek2")', catch_output=True) + + assert result.returncode == 0 + assert stderr_buffer.getvalue() == '' + assert stdout_buffer.getvalue() == '' + + +def test_logging_normal_way(): + logger = MemoryLogger() + + suby(sys.executable, '-c', 'print("kek", end="")', logger=logger, catch_output=True) + + assert len(logger.data.info) == 2 + assert len(logger.data.error) == 0 + + assert logger.data.info[0].message == f'The beginning of the execution of the command "{sys.executable} -c "print("kek", end="")"".' + assert logger.data.info[1].message == f'The command "{sys.executable} -c "print("kek", end="")"" has been successfully executed.' + + +def test_logging_with_expired_timeout(): + logger = MemoryLogger() + + suby(sys.executable, '-c', f'import time; time.sleep({500_000})', logger=logger, catch_exceptions=True, catch_output=True, timeout=0.0001) + + assert len(logger.data.info) == 1 + assert len(logger.data.error) == 1 + + assert logger.data.info[0].message == f'The beginning of the execution of the command "{sys.executable} -c "import time; time.sleep(500000)"".' + assert logger.data.error[0].message == f'The execution of the "{sys.executable} -c "import time; time.sleep(500000)"" command was canceled using a cancellation token.' + + +def test_logging_with_exception(): + logger = MemoryLogger() + + suby(sys.executable, '-c', f'1/0', logger=logger, catch_exceptions=True, catch_output=True) + + assert len(logger.data.info) == 1 + assert len(logger.data.error) == 1 + + assert logger.data.info[0].message == f'The beginning of the execution of the command "{sys.executable} -c 1/0".' + assert logger.data.error[0].message == f'Error when executing the command "{sys.executable} -c 1/0".' + + +def test_logging_with_expired_timeout_without_catching_exceptions(): + logger = MemoryLogger() + + with pytest.raises(TimeoutCancellationError): + suby(sys.executable, '-c', f'import time; time.sleep({500_000})', logger=logger, catch_output=True, timeout=0.0001) + + assert len(logger.data.info) == 1 + assert len(logger.data.error) == 1 + + assert logger.data.info[0].message == f'The beginning of the execution of the command "{sys.executable} -c "import time; time.sleep(500000)"".' + assert logger.data.error[0].message == f'The execution of the "{sys.executable} -c "import time; time.sleep(500000)"" command was canceled using a cancellation token.' + + +def test_logging_with_exception_without_catching_exceptions(): + logger = MemoryLogger() + + with pytest.raises(RunningCommandError): + suby(sys.executable, '-c', f'1/0', logger=logger, catch_output=True) + + assert len(logger.data.info) == 1 + assert len(logger.data.error) == 1 + + assert logger.data.info[0].message == f'The beginning of the execution of the command "{sys.executable} -c 1/0".' + assert logger.data.error[0].message == f'Error when executing the command "{sys.executable} -c 1/0".' + + +def test_only_token(): + sleep_time = 100000 + timeout = 0.1 + + start_time = perf_counter() + token = ConditionToken(lambda: perf_counter() - start_time > timeout) + + result = suby(sys.executable, '-c', f'import time; time.sleep({sleep_time})', catch_exceptions=True, token=token) + + end_time = perf_counter() + + assert result.returncode != 0 + assert result.stdout == '' + assert result.stderr == '' + assert result.killed_by_token == True + + assert end_time - start_time >= timeout + assert end_time - start_time < sleep_time + + +def test_only_token_without_catching(): + sleep_time = 100000 + timeout = 0.1 + + start_time = perf_counter() + token = ConditionToken(lambda: perf_counter() - start_time > timeout) + + try: + result = suby(sys.executable, '-c', f'import time; time.sleep({sleep_time})', token=token) + except ConditionCancellationError as e: + assert e.token is token + result = e.result + + end_time = perf_counter() + + assert result.returncode != 0 + assert result.stdout == '' + assert result.stderr == '' + assert result.killed_by_token == True + + assert end_time - start_time >= timeout + assert end_time - start_time < sleep_time + + +def test_token_plus_timeout_but_timeout_is_more_without_catching(): + sleep_time = 100000 + timeout = 0.1 + + start_time = perf_counter() + token = ConditionToken(lambda: perf_counter() - start_time > timeout) + + try: + result = suby(sys.executable, '-c', f'import time; time.sleep({sleep_time})', token=token, timeout=3) + except ConditionCancellationError as e: + assert e.token is token + result = e.result + + end_time = perf_counter() + + assert result.returncode != 0 + assert result.stdout == '' + assert result.stderr == '' + assert result.killed_by_token == True + + assert end_time - start_time >= timeout + assert end_time - start_time < sleep_time + + +def test_token_plus_timeout_but_timeout_is_less_without_catching(): + sleep_time = 100000 + timeout = 0.1 + + start_time = perf_counter() + token = ConditionToken(lambda: perf_counter() - start_time > timeout) + + try: + result = suby(sys.executable, '-c', f'import time; time.sleep({sleep_time})', token=token, timeout=timeout/2) + except TimeoutCancellationError as e: + assert e.token is not token + result = e.result + + end_time = perf_counter() + + assert result.returncode != 0 + assert result.stdout == '' + assert result.stderr == '' + assert result.killed_by_token == True + + assert end_time - start_time >= timeout/2 + assert end_time - start_time < sleep_time + + +def test_replace_stdout_callback(): + accumulator = [] + + stderr_buffer = StringIO() + stdout_buffer = StringIO() + + with redirect_stdout(stdout_buffer), redirect_stderr(stderr_buffer): + result = suby(sys.executable, '-c', 'print("kek")', stdout_callback=lambda x: accumulator.append(x)) + + assert accumulator == ['kek\n'] + + assert result.returncode == 0 + assert result.stdout == 'kek\n' + assert result.stderr == '' + + assert stderr_buffer.getvalue() == '' + assert stdout_buffer.getvalue() == '' + + +def test_replace_stderr_callback(): + accumulator = [] + + stderr_buffer = StringIO() + stdout_buffer = StringIO() + + with redirect_stdout(stdout_buffer), redirect_stderr(stderr_buffer): + result = suby(sys.executable, '-c', 'import sys; sys.stderr.write("kek")', stderr_callback=lambda x: accumulator.append(x)) + + assert accumulator == ['kek'] + + assert result.returncode == 0 + assert result.stdout == '' + assert result.stderr == 'kek' + + assert stderr_buffer.getvalue() == '' + assert stdout_buffer.getvalue() == '' diff --git a/tests/test_subprocess_result.py b/tests/test_subprocess_result.py new file mode 100644 index 0000000..f99d547 --- /dev/null +++ b/tests/test_subprocess_result.py @@ -0,0 +1,14 @@ +from suby.subprocess_result import SubprocessResult + + +def test_auto_id(): + assert SubprocessResult().id != SubprocessResult().id + assert isinstance(SubprocessResult().id, str) + assert len(SubprocessResult().id) > 10 + + +def test_default_values(): + assert SubprocessResult().stdout is None + assert SubprocessResult().stderr is None + assert SubprocessResult().returncode is None + assert SubprocessResult().killed_by_token == False