Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement streaming pager. Fixes #409 #889

Merged
merged 1 commit into from
May 14, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 26 additions & 20 deletions click/_termui_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import sys
import time
import math

from ._compat import _default_text_stdout, range_type, PY2, isatty, \
open_stream, strip_ansi, term_len, get_best_encoding, WIN, int_types, \
CYGWIN
Expand Down Expand Up @@ -272,35 +273,35 @@ def next(self):
del next


def pager(text, color=None):
def pager(generator, color=None):
"""Decide what method to use for paging through text."""
stdout = _default_text_stdout()
if not isatty(sys.stdin) or not isatty(stdout):
return _nullpager(stdout, text, color)
return _nullpager(stdout, generator, color)
pager_cmd = (os.environ.get('PAGER', None) or '').strip()
if pager_cmd:
if WIN:
return _tempfilepager(text, pager_cmd, color)
return _pipepager(text, pager_cmd, color)
return _tempfilepager(generator, pager_cmd, color)
return _pipepager(generator, pager_cmd, color)
if os.environ.get('TERM') in ('dumb', 'emacs'):
return _nullpager(stdout, text, color)
return _nullpager(stdout, generator, color)
if WIN or sys.platform.startswith('os2'):
return _tempfilepager(text, 'more <', color)
return _tempfilepager(generator, 'more <', color)
if hasattr(os, 'system') and os.system('(less) 2>/dev/null') == 0:
return _pipepager(text, 'less', color)
return _pipepager(generator, 'less', color)

import tempfile
fd, filename = tempfile.mkstemp()
os.close(fd)
try:
if hasattr(os, 'system') and os.system('more "%s"' % filename) == 0:
return _pipepager(text, 'more', color)
return _nullpager(stdout, text, color)
return _pipepager(generator, 'more', color)
return _nullpager(stdout, generator, color)
finally:
os.unlink(filename)


def _pipepager(text, cmd, color):
def _pipepager(generator, cmd, color):
"""Page through text by feeding it to another program. Invoking a
pager through this might support colors.
"""
Expand All @@ -318,17 +319,19 @@ def _pipepager(text, cmd, color):
elif 'r' in less_flags or 'R' in less_flags:
color = True

if not color:
text = strip_ansi(text)

c = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE,
env=env)
encoding = get_best_encoding(c.stdin)
try:
c.stdin.write(text.encode(encoding, 'replace'))
c.stdin.close()
for text in generator:
if not color:
text = strip_ansi(text)

c.stdin.write(text.encode(encoding, 'replace'))
except (IOError, KeyboardInterrupt):
pass
else:
c.stdin.close()

# Less doesn't respect ^C, but catches it for its own UI purposes (aborting
# search or other commands inside less).
Expand All @@ -347,10 +350,12 @@ def _pipepager(text, cmd, color):
break


def _tempfilepager(text, cmd, color):
def _tempfilepager(generator, cmd, color):
"""Page through text by invoking a program on a temporary file."""
import tempfile
filename = tempfile.mktemp()
# TODO: This never terminates if the passed generator never terminates.
text = "".join(generator)

This comment was marked as off-topic.

This comment was marked as off-topic.

This comment was marked as off-topic.

This comment was marked as off-topic.

This comment was marked as off-topic.

This comment was marked as off-topic.

This comment was marked as off-topic.

if not color:
text = strip_ansi(text)
encoding = get_best_encoding(sys.stdout)
Expand All @@ -362,11 +367,12 @@ def _tempfilepager(text, cmd, color):
os.unlink(filename)


def _nullpager(stream, text, color):
def _nullpager(stream, generator, color):
"""Simply print unformatted text. This is the ultimate fallback."""
if not color:
text = strip_ansi(text)
stream.write(text)
for text in generator:
if not color:
text = strip_ansi(text)
stream.write(text)


class Editor(object):
Expand Down
23 changes: 18 additions & 5 deletions click/termui.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import os
import sys
import struct
import inspect
import itertools

from ._compat import raw_input, text_type, string_types, \
isatty, strip_ansi, get_winterm_size, DEFAULT_COLUMNS, WIN
Expand Down Expand Up @@ -203,22 +205,33 @@ def ioctl_gwinsz(fd):
return int(cr[1]), int(cr[0])


def echo_via_pager(text, color=None):
def echo_via_pager(text_or_generator, color=None):
"""This function takes a text and shows it via an environment specific
pager on stdout.

.. versionchanged:: 3.0
Added the `color` flag.

:param text: the text to page.
:param text_or_generator: the text to page, or alternatively, a
generator emitting the text to page.
:param color: controls if the pager supports ANSI colors or not. The
default is autodetection.
"""
color = resolve_color_default(color)
if not isinstance(text, string_types):
text = text_type(text)

if inspect.isgeneratorfunction(text_or_generator):
i = text_or_generator()
elif isinstance(text_or_generator, string_types):
i = [text_or_generator]
else:
i = iter(text_or_generator)

# convert every element of i to a text type if necessary
text_generator = (el if isinstance(el, string_types) else text_type(el)
for el in i)

from ._termui_impl import pager
return pager(text + '\n', color)
return pager(itertools.chain(text_generator, "\n"), color)


def progressbar(iterable=None, length=None, label=None, show_eta=True,
Expand Down
11 changes: 11 additions & 0 deletions docs/utils.rst
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,17 @@ Example:
click.echo_via_pager('\n'.join('Line %d' % idx
for idx in range(200)))

If you want to use the pager for a lot of text, especially if generating everything in advance would take a lot of time, you can pass a generator (or generator function) instead of a string:

.. click:example::
def _generate_output():
for idx in range(50000):
yield "Line %d\n" % idx

@click.command()
def less():
click.echo_via_pager(_generate_output())


Screen Clearing
---------------
Expand Down
28 changes: 25 additions & 3 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,14 +146,36 @@ def f(_):
assert out == 'Password: \nScrew you.\n'


def _test_gen_func():
yield 'a'
yield 'b'
yield 'c'
yield 'abc'


@pytest.mark.skipif(WIN, reason='Different behavior on windows.')
@pytest.mark.parametrize('cat', ['cat', 'cat ', 'cat '])
def test_echo_via_pager(monkeypatch, capfd, cat):
@pytest.mark.parametrize('test', [
# We need lambda here, because pytest will
# reuse the parameters, and then the generators
# are already used and will not yield anymore
('just text\n', lambda: 'just text'),
('iterable\n', lambda: ["itera", "ble"]),
('abcabc\n', lambda: _test_gen_func),
('abcabc\n', lambda: _test_gen_func()),
('012345\n', lambda: (c for c in range(6))),
])
def test_echo_via_pager(monkeypatch, capfd, cat, test):
monkeypatch.setitem(os.environ, 'PAGER', cat)
monkeypatch.setattr(click._termui_impl, 'isatty', lambda x: True)
click.echo_via_pager('haha')

expected_output = test[0]
test_input = test[1]()

click.echo_via_pager(test_input)

out, err = capfd.readouterr()
assert out == 'haha\n'
assert out == expected_output


@pytest.mark.skipif(WIN, reason='Test does not make sense on Windows.')
Expand Down