Skip to content

Commit aadcf89

Browse files
committed
Add file-like pager: click.get_pager_file()
1 parent da7bf34 commit aadcf89

File tree

6 files changed

+113
-66
lines changed

6 files changed

+113
-66
lines changed

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ Unreleased
1212
parameter. :issue:`1264`, :pr:`1329`
1313
- Add an optional parameter to ``ProgressBar.update`` to set the
1414
``current_item``. :issue:`1226`, :pr:`1332`
15+
- Add ``click.get_pager_file`` for file-like access to an output
16+
pager. :pr:`XXXX`
1517

1618

1719
Version 7.1.2

docs/api.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ Utilities
3838

3939
.. autofunction:: echo_via_pager
4040

41+
.. autofunction:: get_pager_file
42+
4143
.. autofunction:: prompt
4244

4345
.. autofunction:: confirm

docs/utils.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,18 @@ If you want to use the pager for a lot of text, especially if generating everyth
119119
click.echo_via_pager(_generate_output())
120120

121121

122+
For more complex programs, which can't easily use a simple generator, you
123+
can get access to a writable file-like object for the pager, and write to
124+
that instead:
125+
126+
.. click:example::
127+
@click.command()
128+
def less():
129+
with click.get_pager_file() as pager:
130+
for idx in range(50000):
131+
print(idx, file=pager)
132+
133+
122134
Screen Clearing
123135
---------------
124136

src/click/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from .termui import confirm
4242
from .termui import echo_via_pager
4343
from .termui import edit
44+
from .termui import get_pager_file
4445
from .termui import get_terminal_size
4546
from .termui import getchar
4647
from .termui import launch

src/click/_termui_impl.py

Lines changed: 75 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
placed in this module and only imported as needed.
55
"""
66
import contextlib
7+
import io
78
import math
89
import os
910
import sys
@@ -13,7 +14,6 @@
1314
from ._compat import CYGWIN
1415
from ._compat import get_best_encoding
1516
from ._compat import isatty
16-
from ._compat import open_stream
1717
from ._compat import strip_ansi
1818
from ._compat import term_len
1919
from ._compat import WIN
@@ -329,62 +329,89 @@ def generator(self):
329329
self.render_progress()
330330

331331

332-
def pager(generator, color=None):
333-
"""Decide what method to use for paging through text."""
332+
class StripAnsi(io.TextIOWrapper):
333+
@classmethod
334+
def maybe(cls, stream, *, color, encoding):
335+
if not getattr(stream, "encoding", None):
336+
if color:
337+
stream = io.TextIOWrapper(stream, encoding=encoding)
338+
else:
339+
stream = cls(stream)
340+
stream.color = color
341+
return stream
342+
343+
def write(self, text):
344+
text = strip_ansi(text)
345+
return super().write(text)
346+
347+
348+
@contextlib.contextmanager
349+
def get_pager_file(color=None):
350+
"""Context manager.
351+
352+
Yields a writable file-like object which can be used as an output pager.
353+
354+
.. versionadded:: 8.0
355+
356+
:param color: controls if the pager supports ANSI colors or not. The
357+
default is autodetection.
358+
"""
334359
stdout = _default_text_stdout()
335-
if not isatty(sys.stdin) or not isatty(stdout):
336-
return _nullpager(stdout, generator, color)
337360
pager_cmd = (os.environ.get("PAGER", None) or "").strip()
361+
env = dict(os.environ)
338362
if pager_cmd:
363+
# If we're piping to less we might support colors
364+
# if the right flags are passed...
365+
cmd_detail = pager_cmd.rsplit("/", 1)[-1].split()
366+
if color is None and cmd_detail[0] == "less":
367+
less_flags = f"{os.environ.get('LESS', '')}{' '.join(cmd_detail[1:])}"
368+
if not less_flags:
369+
env["LESS"] = "-R"
370+
color = True
371+
elif "r" in less_flags or "R" in less_flags:
372+
color = True
373+
if not isatty(sys.stdin) or not isatty(stdout):
374+
ctx = contextlib.nullcontext((stdout, None))
375+
elif pager_cmd:
339376
if WIN:
340-
return _tempfilepager(generator, pager_cmd, color)
341-
return _pipepager(generator, pager_cmd, color)
342-
if os.environ.get("TERM") in ("dumb", "emacs"):
343-
return _nullpager(stdout, generator, color)
344-
if WIN or sys.platform.startswith("os2"):
345-
return _tempfilepager(generator, "more <", color)
346-
if hasattr(os, "system") and os.system("(less) 2>/dev/null") == 0:
347-
return _pipepager(generator, "less", color)
377+
ctx = _tempfilepager(pager_cmd)
378+
else:
379+
ctx = _pipepager(pager_cmd, env=env)
380+
elif os.environ.get("TERM") in ("dumb", "emacs"):
381+
ctx = contextlib.nullcontext((stdout, None))
382+
elif WIN or sys.platform.startswith("os2"):
383+
ctx = _tempfilepager("more <")
384+
elif hasattr(os, "system") and os.system("(less) 2>/dev/null") == 0:
385+
ctx = _pipepager("less", env=env)
386+
else:
387+
import tempfile
348388

349-
import tempfile
389+
fd, filename = tempfile.mkstemp()
390+
os.close(fd)
391+
try:
392+
if hasattr(os, "system") and os.system(f'more "{filename}"') == 0:
393+
ctx = _pipepager("more")
394+
else:
395+
ctx = contextlib.nullcontext((stdout, None))
396+
finally:
397+
os.unlink(filename)
350398

351-
fd, filename = tempfile.mkstemp()
352-
os.close(fd)
353-
try:
354-
if hasattr(os, "system") and os.system(f'more "{filename}"') == 0:
355-
return _pipepager(generator, "more", color)
356-
return _nullpager(stdout, generator, color)
357-
finally:
358-
os.unlink(filename)
399+
with ctx as (stream, encoding):
400+
with StripAnsi.maybe(stream, color=color, encoding=encoding) as text_stream:
401+
yield text_stream
359402

360403

361-
def _pipepager(generator, cmd, color):
404+
@contextlib.contextmanager
405+
def _pipepager(cmd, env=None):
362406
"""Page through text by feeding it to another program. Invoking a
363407
pager through this might support colors.
364408
"""
365409
import subprocess
366410

367-
env = dict(os.environ)
368-
369-
# If we're piping to less we might support colors under the
370-
# condition that
371-
cmd_detail = cmd.rsplit("/", 1)[-1].split()
372-
if color is None and cmd_detail[0] == "less":
373-
less_flags = f"{os.environ.get('LESS', '')}{' '.join(cmd_detail[1:])}"
374-
if not less_flags:
375-
env["LESS"] = "-R"
376-
color = True
377-
elif "r" in less_flags or "R" in less_flags:
378-
color = True
379-
380411
c = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, env=env)
381-
encoding = get_best_encoding(c.stdin)
382412
try:
383-
for text in generator:
384-
if not color:
385-
text = strip_ansi(text)
386-
387-
c.stdin.write(text.encode(encoding, "replace"))
413+
encoding = get_best_encoding(c.stdin)
414+
yield c.stdin, encoding
388415
except (OSError, KeyboardInterrupt):
389416
pass
390417
else:
@@ -407,30 +434,16 @@ def _pipepager(generator, cmd, color):
407434
break
408435

409436

410-
def _tempfilepager(generator, cmd, color):
437+
@contextlib.contextmanager
438+
def _tempfilepager(cmd):
411439
"""Page through text by invoking a program on a temporary file."""
412440
import tempfile
413441

414-
filename = tempfile.mktemp()
415-
# TODO: This never terminates if the passed generator never terminates.
416-
text = "".join(generator)
417-
if not color:
418-
text = strip_ansi(text)
419442
encoding = get_best_encoding(sys.stdout)
420-
with open_stream(filename, "wb")[0] as f:
421-
f.write(text.encode(encoding))
422-
try:
423-
os.system(f'{cmd} "{filename}"')
424-
finally:
425-
os.unlink(filename)
426-
427-
428-
def _nullpager(stream, generator, color):
429-
"""Simply print unformatted text. This is the ultimate fallback."""
430-
for text in generator:
431-
if not color:
432-
text = strip_ansi(text)
433-
stream.write(text)
443+
with tempfile.NamedTemporaryFile(mode="wb") as f:
444+
yield f, encoding
445+
f.flush()
446+
os.system(f'{cmd} "{f.name}"')
434447

435448

436449
class Editor:

src/click/termui.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,23 @@ def ioctl_gwinsz(fd):
255255
return int(cr[1]), int(cr[0])
256256

257257

258+
def get_pager_file(color=None):
259+
"""Context manager.
260+
261+
Yields a writable file-like object which can be used as an output pager.
262+
263+
.. versionadded:: 8.0
264+
265+
:param color: controls if the pager supports ANSI colors or not. The
266+
default is autodetection.
267+
"""
268+
from ._termui_impl import get_pager_file
269+
270+
color = resolve_color_default(color)
271+
272+
return get_pager_file(color=color)
273+
274+
258275
def echo_via_pager(text_or_generator, color=None):
259276
"""This function takes a text and shows it via an environment specific
260277
pager on stdout.
@@ -267,7 +284,6 @@ def echo_via_pager(text_or_generator, color=None):
267284
:param color: controls if the pager supports ANSI colors or not. The
268285
default is autodetection.
269286
"""
270-
color = resolve_color_default(color)
271287

272288
if inspect.isgeneratorfunction(text_or_generator):
273289
i = text_or_generator()
@@ -279,9 +295,10 @@ def echo_via_pager(text_or_generator, color=None):
279295
# convert every element of i to a text type if necessary
280296
text_generator = (el if isinstance(el, str) else str(el) for el in i)
281297

282-
from ._termui_impl import pager
283-
284-
return pager(itertools.chain(text_generator, "\n"), color)
298+
with get_pager_file(color=color) as pager:
299+
for text in itertools.chain(text_generator, "\n"):
300+
# pager.write(text.encode('utf-8'))
301+
pager.write(text)
285302

286303

287304
def progressbar(

0 commit comments

Comments
 (0)