Skip to content

Commit b544c40

Browse files
craigdsAndreasBackx
authored andcommitted
Add file-like pager: click.get_pager_file()
1 parent 244d562 commit b544c40

File tree

7 files changed

+101
-32
lines changed

7 files changed

+101
-32
lines changed

CHANGES.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ Unreleased
7979
allows the user to search for future output of the generator when
8080
using less and then aborting the program using ctrl-c.
8181

82+
- Add ``click.get_pager_file`` for file-like access to an output
83+
pager. :pr:`1572`
84+
85+
8286
Version 8.1.8
8387
-------------
8488

docs/api.rst

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

4242
.. autofunction:: echo_via_pager
4343

44+
.. autofunction:: get_pager_file
45+
4446
.. autofunction:: prompt
4547

4648
.. autofunction:: confirm

docs/utils.rst

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

115115

116+
For more complex programs, which can't easily use a simple generator, you
117+
can get access to a writable file-like object for the pager, and write to
118+
that instead:
119+
120+
.. click:example::
121+
@click.command()
122+
def less():
123+
with click.get_pager_file() as pager:
124+
for idx in range(50000):
125+
print(idx, file=pager)
126+
127+
116128
Screen Clearing
117129
---------------
118130

src/click/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from .termui import confirm as confirm
4343
from .termui import echo_via_pager as echo_via_pager
4444
from .termui import edit as edit
45+
from .termui import get_pager_file as get_pager_file
4546
from .termui import getchar as getchar
4647
from .termui import launch as launch
4748
from .termui import pause as pause

src/click/_compat.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,10 @@ def should_strip_ansi(
502502
if color is None:
503503
if stream is None:
504504
stream = sys.stdin
505+
elif hasattr(stream, "color"):
506+
# ._termui_impl.MaybeStripAnsi handles stripping ansi itself,
507+
# so we don't need to strip it here
508+
return False
505509
return not isatty(stream) and not _is_jupyter_kernel_output(stream)
506510
return not color
507511

src/click/_termui_impl.py

Lines changed: 58 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import collections.abc as cabc
1010
import contextlib
11+
import io
1112
import math
1213
import os
1314
import sys
@@ -363,7 +364,20 @@ def generator(self) -> cabc.Iterator[V]:
363364
self.render_progress()
364365

365366

366-
def _pager_contextmanager(color: bool | None = None):
367+
class MaybeStripAnsi(io.TextIOWrapper):
368+
def __init__(self, stream: t.IO[bytes], *, color: bool, **kwargs: t.Any):
369+
super().__init__(stream, **kwargs)
370+
self.color = color
371+
372+
def write(self, text: str) -> int:
373+
if not self.color:
374+
text = strip_ansi(text)
375+
return super().write(text)
376+
377+
378+
def _pager_contextmanager(
379+
color: bool | None = None,
380+
) -> t.ContextManager[t.Tuple[t.BinaryIO, str, bool]]:
367381
"""Decide what method to use for paging through text."""
368382
stdout = _default_text_stdout()
369383

@@ -398,17 +412,26 @@ def _pager_contextmanager(color: bool | None = None):
398412
os.unlink(filename)
399413

400414

401-
def pager(generator: cabc.Iterable[str], color: bool | None = None):
402-
"""Given an iterable of text, write it all to an output pager."""
403-
with _pager_contextmanager(color=color) as (pager_file, encoding, color):
404-
for text in generator:
405-
if not color:
406-
text = strip_ansi(text)
407-
pager_file.write(text.encode(encoding, "replace"))
415+
@contextlib.contextmanager
416+
def get_pager_file(color: bool | None = None) -> t.Generator[t.IO, None, None]:
417+
"""Context manager.
418+
Yields a writable file-like object which can be used as an output pager.
419+
.. versionadded:: 8.2
420+
:param color: controls if the pager supports ANSI colors or not. The
421+
default is autodetection.
422+
"""
423+
with _pager_contextmanager(color=color) as (stream, encoding, color):
424+
if not getattr(stream, "encoding", None):
425+
# wrap in a text stream
426+
stream = MaybeStripAnsi(stream, color=color, encoding=encoding)
427+
yield stream
428+
stream.flush()
408429

409430

410431
@contextlib.contextmanager
411-
def _pipepager(cmd: str, color: bool | None = None):
432+
def _pipepager(
433+
cmd: str, color: bool | None
434+
) -> t.Iterator[t.Tuple[t.BinaryIO, str, bool]]:
412435
"""Page through text by feeding it to another program. Invoking a
413436
pager through this might support colors.
414437
"""
@@ -427,6 +450,9 @@ def _pipepager(cmd: str, color: bool | None = None):
427450
elif "r" in less_flags or "R" in less_flags:
428451
color = True
429452

453+
if color is None:
454+
color = False
455+
430456
c = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, env=env)
431457
stdin = t.cast(t.BinaryIO, c.stdin)
432458
encoding = get_best_encoding(stdin)
@@ -469,7 +495,9 @@ def _pipepager(cmd: str, color: bool | None = None):
469495

470496

471497
@contextlib.contextmanager
472-
def _tempfilepager(cmd: str, color: bool | None = None):
498+
def _tempfilepager(
499+
cmd: str, color: bool | None = None
500+
) -> t.Iterator[t.Tuple[t.BinaryIO, str, bool]]:
473501
"""Page through text by invoking a program on a temporary file."""
474502
import tempfile
475503

@@ -481,10 +509,12 @@ def _tempfilepager(cmd: str, color: bool | None = None):
481509

482510

483511
@contextlib.contextmanager
484-
def _nullpager(stream: t.TextIO, color: bool | None = None):
512+
def _nullpager(
513+
stream: t.TextIO, color: bool | None = None
514+
) -> t.Iterator[t.Tuple[t.BinaryIO, str, bool]]:
485515
"""Simply print unformatted text. This is the ultimate fallback."""
486516
encoding = get_best_encoding(stream)
487-
return stream, encoding, color
517+
yield stream, encoding, color
488518

489519

490520
class Editor:
@@ -629,23 +659,23 @@ def _unquote_file(url: str) -> str:
629659
wait_str = "-w" if wait else ""
630660
args = f'cygstart {wait_str} "{url}"'
631661
return os.system(args)
632-
633-
try:
634-
if locate:
635-
url = os.path.dirname(_unquote_file(url)) or "."
636-
else:
637-
url = _unquote_file(url)
638-
c = subprocess.Popen(["xdg-open", url])
639-
if wait:
640-
return c.wait()
641-
return 0
642-
except OSError:
643-
if url.startswith(("http://", "https://")) and not locate and not wait:
644-
import webbrowser
645-
646-
webbrowser.open(url)
662+
else:
663+
try:
664+
if locate:
665+
url = os.path.dirname(_unquote_file(url)) or "."
666+
else:
667+
url = _unquote_file(url)
668+
c = subprocess.Popen(["xdg-open", url])
669+
if wait:
670+
return c.wait()
647671
return 0
648-
return 1
672+
except OSError:
673+
if url.startswith(("http://", "https://")) and not locate and not wait:
674+
import webbrowser
675+
676+
webbrowser.open(url)
677+
return 0
678+
return 1
649679

650680

651681
def _translate_ch_to_exc(ch: str) -> None:

src/click/termui.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,23 @@ def confirm(
252252
return rv
253253

254254

255+
def get_pager_file(color=None):
256+
"""Context manager.
257+
258+
Yields a writable file-like object which can be used as an output pager.
259+
260+
.. versionadded:: 8.2
261+
262+
:param color: controls if the pager supports ANSI colors or not. The
263+
default is autodetection.
264+
"""
265+
from ._termui_impl import get_pager_file
266+
267+
color = resolve_color_default(color)
268+
269+
return get_pager_file(color=color)
270+
271+
255272
def echo_via_pager(
256273
text_or_generator: cabc.Iterable[str] | t.Callable[[], cabc.Iterable[str]] | str,
257274
color: bool | None = None,
@@ -267,7 +284,6 @@ def echo_via_pager(
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 = t.cast("t.Callable[[], cabc.Iterable[str]]", text_or_generator)()
@@ -279,9 +295,9 @@ def echo_via_pager(
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)
285301

286302

287303
@t.overload

0 commit comments

Comments
 (0)