prompt-toolkit / python-prompt-toolkit

Library for building powerful interactive command line applications in Python
https://python-prompt-toolkit.readthedocs.io/
BSD 3-Clause "New" or "Revised" License
9.1k stars 717 forks source link

Incompatibility with pytest capsys #1852

Open vallsv opened 4 months ago

vallsv commented 4 months ago

Hi,

Here is something i have noticed while testing the project i manage.

Sounds like print_formatted_text and capsys does not interoperate together.

Do you have any idea how to deal with that problem? Does it make sense to patch one or the other project?

Test

from prompt_toolkit import print_formatted_text

def test_1(capsys):
    print_formatted_text("1")

def test_2(capsys):
    print_formatted_text("2")

Result

test_pt.py::test_1 PASSED                                                                                                                              [ 50%]
test_pt.py::test_2 FAILED                                                                                                                              [100%]

========================================================================== FAILURES ==========================================================================
___________________________________________________________________________ test_2 ___________________________________________________________________________

capsys = <_pytest.capture.CaptureFixture object at 0x7fbc1f55df10>

    def test_2(capsys):
>       print_formatted_text("2")

test_pt.py:12: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
../../Software/miniconda3/envs/blissdev/lib/python3.9/site-packages/prompt_toolkit/shortcuts/utils.py:163: in print_formatted_text
    render()
../../Software/miniconda3/envs/blissdev/lib/python3.9/site-packages/prompt_toolkit/shortcuts/utils.py:138: in render
    renderer_print_formatted_text(
../../Software/miniconda3/envs/blissdev/lib/python3.9/site-packages/prompt_toolkit/renderer.py:813: in print_formatted_text
    output.flush()
../../Software/miniconda3/envs/blissdev/lib/python3.9/site-packages/prompt_toolkit/output/plain_text.py:57: in flush
    flush_stdout(self.stdout, data)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

stdout = <_io.TextIOWrapper encoding='UTF-8'>, data = '2\r\n'

    def flush_stdout(stdout: TextIO, data: str) -> None:
        # If the IO object has an `encoding` and `buffer` attribute, it means that
        # we can access the underlying BinaryIO object and write into it in binary
        # mode. This is preferred if possible.
        # NOTE: When used in a Jupyter notebook, don't write binary.
        #       `ipykernel.iostream.OutStream` has an `encoding` attribute, but not
        #       a `buffer` attribute, so we can't write binary in it.
        has_binary_io = hasattr(stdout, "encoding") and hasattr(stdout, "buffer")

        try:
            # Ensure that `stdout` is made blocking when writing into it.
            # Otherwise, when uvloop is activated (which makes stdout
            # non-blocking), and we write big amounts of text, then we get a
            # `BlockingIOError` here.
            with _blocking_io(stdout):
                # (We try to encode ourself, because that way we can replace
                # characters that don't exist in the character set, avoiding
                # UnicodeEncodeError crashes. E.g. u'\xb7' does not appear in 'ascii'.)
                # My Arch Linux installation of july 2015 reported 'ANSI_X3.4-1968'
                # for sys.stdout.encoding in xterm.
                out: IO[bytes]
                if has_binary_io:
>                   stdout.buffer.write(data.encode(stdout.encoding or "utf-8", "replace"))
E                   ValueError: I/O operation on closed file.

../../Software/miniconda3/envs/blissdev/lib/python3.9/site-packages/prompt_toolkit/output/flush_stdout.py:32: ValueError
================================================================== short test summary info ===================================================================
FAILED test_pt.py::test_2 - ValueError: I/O operation on closed file.

My env

pytest                        7.2.0
pytest-cov                    4.0.0
pytest-mock                   3.10.0
pytest-profiling              1.7.0
pytest-redis                  3.0.2
pytest-rerunfailures          10.3
pytest-xvfb                   2.0.0
prompt-toolkit                3.0.33
vallsv commented 4 months ago

Sounds like it is because of the app context from the contextvar.

from prompt_toolkit.application import current

def test_1(capsys):
    print(current._current_app_session.get())
    print(current._current_app_session.get().output.stdout)
    print(current._current_app_session.get().output.stdout.closed)
    print_formatted_text("1", file=sys.stdout)

def test_2(capsys):
    print(current._current_app_session.get())
    print(current._current_app_session.get().output.stdout)
    print(current._current_app_session.get().output.stdout.closed)
    print_formatted_text("2", file=sys.stdout)
test_pt.py::test_1 AppSession(app=None)
<_io.TextIOWrapper encoding='UTF-8'>
False
1
PASSED
test_pt.py::test_2 AppSession(app=None)
<_io.TextIOWrapper encoding='UTF-8'>
True
2
PASSED

It is easy to patch the tests. But is there any chance to make it work without local fixes?

I would prefer that the tests were not aware that they use prompt toolkit.

vallsv commented 4 months ago

So, here is my workaround for now.

I haven't found an easy way to clear the contextvars for the general use case. And i have to patch capsys fixture.

But it works without changing the tests, which is good, i think.

# conftest.py

import contextvars
import pytest
from prompt_toolkit.application import current

@pytest.fixture
def clear_pt_context():
    """Clear the context used by prompt-toolkit in order to isolate tests"""
    yield
    # FIXME: Would be better to only clear the value.
    #        But i haven't found the way.
    current._current_app_session = contextvars.ContextVar(
        "_current_app_session", default=current.AppSession()
    )

@pytest.fixture
def capsys(clear_pt_context, capsys):
    """Monkey patch capsys to make it compatible with prompt-toolkit

    capsys replace sys.stdout, then prompt toolkit creates a context on it.
    This mocked stdout is finally closed, but the prompt toolkit context
    still point to it. `clear_pt_context` force to drop the pt context.
    """
    yield capsys