Skip to content

Commit 33ede8c

Browse files
committed
Add file-like pager: click.get_pager_file()
1 parent c479b1a commit 33ede8c

File tree

7 files changed

+104
-38
lines changed

7 files changed

+104
-38
lines changed

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ Unreleased
3131
- When generating a command's name from a decorated function's name, the
3232
suffixes ``_command``, ``_cmd``, ``_group``, and ``_grp`` are removed.
3333
:issue:`2322`
34+
- Add ``click.get_pager_file`` for file-like access to an output
35+
pager. :pr:`1572`
3436

3537

3638
Version 8.1.8

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
@@ -41,6 +41,7 @@
4141
from .termui import confirm as confirm
4242
from .termui import echo_via_pager as echo_via_pager
4343
from .termui import edit as edit
44+
from .termui import get_pager_file as get_pager_file
4445
from .termui import getchar as getchar
4546
from .termui import launch as launch
4647
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: 63 additions & 34 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
@@ -360,7 +361,20 @@ def generator(self) -> cabc.Iterator[V]:
360361
self.render_progress()
361362

362363

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

@@ -395,17 +409,26 @@ def _pager_contextmanager(color: bool | None = None):
395409
os.unlink(filename)
396410

397411

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

406427

407428
@contextlib.contextmanager
408-
def _pipepager(cmd: str, color: bool | None = None):
429+
def _pipepager(
430+
cmd: str, color: bool | None
431+
) -> t.Iterator[t.Tuple[t.BinaryIO, str, bool]]:
409432
"""Page through text by feeding it to another program. Invoking a
410433
pager through this might support colors.
411434
"""
@@ -424,15 +447,17 @@ def _pipepager(cmd: str, color: bool | None = None):
424447
elif "r" in less_flags or "R" in less_flags:
425448
color = True
426449

450+
if color is None:
451+
color = False
452+
427453
c = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, env=env)
428454
stdin = t.cast(t.BinaryIO, c.stdin)
429455
encoding = get_best_encoding(stdin)
430-
try:
431-
yield stdin, encoding, color
432-
except (OSError, KeyboardInterrupt):
433-
pass
434-
else:
435-
stdin.close()
456+
with stdin:
457+
try:
458+
yield stdin, encoding, color
459+
except (OSError, KeyboardInterrupt):
460+
pass
436461

437462
# Less doesn't respect ^C, but catches it for its own UI purposes (aborting
438463
# search or other commands inside less).
@@ -452,7 +477,9 @@ def _pipepager(cmd: str, color: bool | None = None):
452477

453478

454479
@contextlib.contextmanager
455-
def _tempfilepager(cmd: str, color: bool | None = None):
480+
def _tempfilepager(
481+
cmd: str, color: bool | None = None
482+
) -> t.Iterator[t.Tuple[t.BinaryIO, str, bool]]:
456483
"""Page through text by invoking a program on a temporary file."""
457484
import tempfile
458485

@@ -464,10 +491,12 @@ def _tempfilepager(cmd: str, color: bool | None = None):
464491

465492

466493
@contextlib.contextmanager
467-
def _nullpager(stream: t.TextIO, color: bool | None = None):
494+
def _nullpager(
495+
stream: t.TextIO, color: bool | None = None
496+
) -> t.Iterator[t.Tuple[t.BinaryIO, str, bool]]:
468497
"""Simply print unformatted text. This is the ultimate fallback."""
469498
encoding = get_best_encoding(stream)
470-
return stream, encoding, color
499+
yield stream, encoding, color
471500

472501

473502
class Editor:
@@ -608,23 +637,23 @@ def _unquote_file(url: str) -> str:
608637
wait_str = "-w" if wait else ""
609638
args = f'cygstart {wait_str} "{url}"'
610639
return os.system(args)
611-
612-
try:
613-
if locate:
614-
url = os.path.dirname(_unquote_file(url)) or "."
615-
else:
616-
url = _unquote_file(url)
617-
c = subprocess.Popen(["xdg-open", url])
618-
if wait:
619-
return c.wait()
620-
return 0
621-
except OSError:
622-
if url.startswith(("http://", "https://")) and not locate and not wait:
623-
import webbrowser
624-
625-
webbrowser.open(url)
640+
else:
641+
try:
642+
if locate:
643+
url = os.path.dirname(_unquote_file(url)) or "."
644+
else:
645+
url = _unquote_file(url)
646+
c = subprocess.Popen(["xdg-open", url])
647+
if wait:
648+
return c.wait()
626649
return 0
627-
return 1
650+
except OSError:
651+
if url.startswith(("http://", "https://")) and not locate and not wait:
652+
import webbrowser
653+
654+
webbrowser.open(url)
655+
return 0
656+
return 1
628657

629658

630659
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
def progressbar(

0 commit comments

Comments
 (0)