Skip to content
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,14 +235,14 @@ run('python -c "print(\'hello, world!\')"', stdout_callback=my_new_stdout)
# You can't see it here, but if you run this code yourself, the output in the console will be red!
```

You can also completely disable the output by passing `True` as the `catch_output` parameter:
You can also disable the default forwarding to the current process by passing `True` as the `catch_output` parameter:

```python
run('python -c "print(\'hello, world!\')"', catch_output=True)
# There's nothing here.
```

If you specify `catch_output=True`, even if you have also defined custom callback functions, they will not be called. 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 suppress only the output, but it does not prevent the buffering of output.
If you specify `catch_output=True`, the default `stdout` and `stderr` forwarding callbacks are suppressed, so subprocess output is not printed by `suby` automatically. Custom `stdout_callback` and `stderr_callback` functions are still called for each line. 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 suppress only the default console forwarding, but it does not prevent custom callback delivery or result buffering.

<details>
<summary>Notes about concurrent output</summary>
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "suby"
version = "0.0.7"
version = "0.0.8"
authors = [
{ name="Evgeniy Blinov", email="zheni-b@yandex.ru" },
]
Expand All @@ -14,7 +14,8 @@ requires-python = ">=3.8"
dependencies = [
'emptylog>=0.0.12',
'cantok>=0.0.36',
'microbenchmark>=0.0.2',
'microbenchmark>=0.0.3',
'sigmatch>=0.0.9',
]
classifiers = [
"Operating System :: OS Independent",
Expand Down
56 changes: 55 additions & 1 deletion suby/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
import stat
from collections.abc import Mapping as RuntimeMapping
from dataclasses import dataclass, field
from functools import partial
from inspect import (
isasyncgenfunction,
isclass,
iscoroutinefunction,
isgeneratorfunction,
)
from math import isfinite
from pathlib import Path
from platform import system
Expand Down Expand Up @@ -35,6 +42,7 @@
from cantok import ConditionCancellationError as CantokConditionCancellationError
from cantok import TimeoutCancellationError as CantokTimeoutCancellationError
from emptylog import EmptyLogger, LoggerProtocol
from sigmatch import PossibleCallMatcher, SignatureMismatchError

from suby.callbacks import stderr_with_flush, stdout_with_flush
from suby.errors import (
Expand All @@ -49,6 +57,7 @@
from suby.subprocess_result import SubprocessResult

StreamCallback = Callable[[str], Any] # type: ignore[misc, unused-ignore]
STREAM_CALLBACK_MATCHER = PossibleCallMatcher('.')
_CUSTOM_TOKEN_POLL_TIMEOUT_SECONDS = 0.0001
_CANCELLATION_ERROR_TYPES: Mapping[Type[CancellationError], Type[CancellationError]] = {
CantokConditionCancellationError: ConditionCancellationError,
Expand Down Expand Up @@ -143,6 +152,9 @@ def run( # noqa: PLR0913, PLR0915
if subprocess_directory is not None:
popen_kwargs['cwd'] = subprocess_directory

check_output_stream_callback('stdout_callback', stdout_callback)
check_output_stream_callback('stderr_callback', stderr_callback)

state = _ExecutionState()

logger.info(f'The beginning of the execution of the command "{arguments_string_representation}".')
Expand Down Expand Up @@ -258,6 +270,42 @@ def split_argument(argument: str, double_backslash: bool) -> List[str]:
return shlex_split(argument)


def check_output_stream_callback(parameter_name: str, callback: Any) -> None: # type: ignore[misc]
def build_message(reason: str) -> str:
return (
f'{parameter_name} must be a synchronous, non-generator callable that can be invoked as callback(line), ' # type: ignore[misc, unused-ignore]
f'where line is a str output line; got {callback!r} ({type(callback).__name__}). {reason}' # type: ignore[misc, unused-ignore]
)

if not callable(callback): # type: ignore[misc]
raise SignatureMismatchError(build_message('The value is not callable.'))

inspected_callback = callback # type: ignore[misc]
partial_type: type[object] = type(partial(len))
while isinstance(inspected_callback, partial_type): # type: ignore[misc]
inspected_callback = object.__getattribute__(inspected_callback, 'func') # type: ignore[misc]

if isclass(inspected_callback): # type: ignore[misc]
raise SignatureMismatchError(build_message('callback classes are not supported; pass a function or a callable instance instead.'))

try:
inspected_call = object.__getattribute__(inspected_callback, '__call__') # type: ignore[misc]
except AttributeError:
inspected_call = None
for inspected in (inspected_callback, inspected_call): # type: ignore[misc]
if inspected is None: # type: ignore[misc]
continue
if iscoroutinefunction(inspected): # type: ignore[misc]
raise SignatureMismatchError(build_message('async callbacks are not supported because suby invokes stream callbacks synchronously.'))
if isasyncgenfunction(inspected): # type: ignore[misc]
raise SignatureMismatchError(build_message('async generator callbacks are not supported because suby invokes stream callbacks synchronously and ignores callback return values.'))
if isgeneratorfunction(inspected): # type: ignore[misc]
raise SignatureMismatchError(build_message('generator callbacks are not supported because suby invokes stream callbacks synchronously and ignores callback return values.'))

if not STREAM_CALLBACK_MATCHER.match(callback, raise_exception=False): # type: ignore[misc]
raise SignatureMismatchError(build_message('The callable signature does not accept one positional output line.'))


def prepare_directory(directory: Optional[Union[str, Path]]) -> Optional[str]:
if directory is None:
return None
Expand Down Expand Up @@ -502,14 +550,20 @@ def read_stream( # noqa: PLR0913
if state.failure_state.error is not None:
return
buffer.append(line)
if not catch_output:
if should_call_stream_callback(catch_output, callback):
callback(line)
except Exception as error: # noqa: BLE001
if state.failure_state.set(error):
state.wake_event.set()
return


def should_call_stream_callback(catch_output: bool, callback: StreamCallback) -> bool:
if not catch_output:
return True
return callback not in (stdout_with_flush, stderr_with_flush)


def wait_for_process_exit_and_signal(process: Popen[str], state: _ExecutionState) -> None:
wait_for_process_exit(process, None)
if process.returncode is None:
Expand Down
15 changes: 15 additions & 0 deletions tests/documentation/test_readme.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,21 @@ def test_catch_output_suppresses_console_output():
assert result.stdout == 'hello, world!\n'


def test_catch_output_keeps_custom_callbacks():
"""catch_output=True suppresses default console forwarding without bypassing custom callbacks."""
collected = []
stderr_buffer = StringIO()
stdout_buffer = StringIO()

with redirect_stdout(stdout_buffer), redirect_stderr(stderr_buffer):
result = run('python -c "print(\'hello, world!\')"', catch_output=True, stdout_callback=collected.append)

assert collected == ['hello, world!\n']
assert stdout_buffer.getvalue() == ''
assert stderr_buffer.getvalue() == ''
assert result.stdout == 'hello, world!\n'


@pytest.mark.parametrize(
('command', 'run_kwargs', 'expected_info', 'expected_error'),
[
Expand Down
33 changes: 32 additions & 1 deletion tests/typing/test_typing_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,14 +260,36 @@ def callback_with_extra_argument(line: str, extra: int) -> None:
def callback_with_bytes_input(line: bytes) -> None:
pass

class CallableCallbackWithoutArguments:
def __call__(self) -> None:
pass

class CallableCallbackWithExtraArgument:
def __call__(self, line: str, extra: int) -> None:
pass

class CallableCallbackWithBytesInput:
def __call__(self, line: bytes) -> None:
pass

run('python -c pass', stdout_callback=callback_without_arguments) # E: [arg-type]
run('python -c pass', stderr_callback=callback_without_arguments) # E: [arg-type]
run('python -c pass', stdout_callback=callback_with_extra_argument) # E: [arg-type]
run('python -c pass', stderr_callback=callback_with_extra_argument) # E: [arg-type]
run('python -c pass', stdout_callback=callback_with_bytes_input) # E: [arg-type]
run('python -c pass', stderr_callback=callback_with_bytes_input) # E: [arg-type]
run('python -c pass', stdout_callback=CallableCallbackWithoutArguments().__call__) # E: [arg-type]
run('python -c pass', stderr_callback=CallableCallbackWithoutArguments().__call__) # E: [arg-type]
run('python -c pass', stdout_callback=CallableCallbackWithExtraArgument().__call__) # E: [arg-type]
run('python -c pass', stderr_callback=CallableCallbackWithExtraArgument().__call__) # E: [arg-type]
run('python -c pass', stdout_callback=CallableCallbackWithBytesInput().__call__) # E: [arg-type]
run('python -c pass', stderr_callback=CallableCallbackWithBytesInput().__call__) # E: [arg-type]
run('python -c pass', stdout_callback=None) # E: [arg-type]
run('python -c pass', stderr_callback=None) # E: [arg-type]
run('python -c pass', stdout_callback=123) # E: [arg-type]
run('python -c pass', stderr_callback=123) # E: [arg-type]
run('python -c pass', stdout_callback=object()) # E: [arg-type]
run('python -c pass', stderr_callback=object()) # E: [arg-type]


@pytest.mark.mypy_testing
Expand Down Expand Up @@ -472,13 +494,22 @@ def test_run_static_return_type_is_not_changed_by_catch_exceptions() -> None:

@pytest.mark.mypy_testing
def test_run_known_typing_gaps_are_currently_accepted() -> None:
"""Current known gaps: mypy still accepts run() with no command args, timeout=True, and async callbacks."""
"""Current known gaps: mypy still accepts cases that runtime validation rejects."""
async def async_callback(line: str) -> None:
pass

def generator_callback(line: str):
yield line

class CallbackClass:
def __init__(self, line: str) -> None:
pass

run()
run('python -c pass', timeout=True)
run('python -c pass', stdout_callback=async_callback)
run('python -c pass', stderr_callback=generator_callback)
run('python -c pass', stdout_callback=CallbackClass)


@pytest.mark.mypy_testing
Expand Down
Loading
Loading