Skip to content

Commit 24929b0

Browse files
committed
Refactor echo_via_pager to expose file objects
This better exposes the file-like objects yielded by the three private pager functions, and also slightly reduces some code duplication
1 parent 6c894eb commit 24929b0

File tree

1 file changed

+83
-74
lines changed

1 file changed

+83
-74
lines changed

src/click/_termui_impl.py

Lines changed: 83 additions & 74 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 shlex
@@ -23,7 +24,6 @@
2324
from ._compat import CYGWIN
2425
from ._compat import get_best_encoding
2526
from ._compat import isatty
26-
from ._compat import open_stream
2727
from ._compat import strip_ansi
2828
from ._compat import term_len
2929
from ._compat import WIN
@@ -366,7 +366,20 @@ def generator(self) -> cabc.Iterator[V]:
366366
self.render_progress()
367367

368368

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

@@ -376,50 +389,52 @@ def pager(generator: cabc.Iterable[str], color: bool | None = None) -> None:
376389
stdout = StringIO()
377390

378391
if not isatty(sys.stdin) or not isatty(stdout):
379-
return _nullpager(stdout, generator, color)
392+
return _nullpager(stdout, color)
380393

381394
# Split and normalize the pager command into parts.
382395
pager_cmd_parts = shlex.split(os.environ.get("PAGER", ""), posix=False)
383396
if pager_cmd_parts:
384397
if WIN:
385-
if _tempfilepager(generator, pager_cmd_parts, color):
386-
return
387-
elif _pipepager(generator, pager_cmd_parts, color):
388-
return
398+
return _tempfilepager(pager_cmd_parts, color)
399+
return _pipepager(pager_cmd_parts, color)
389400

390401
if os.environ.get("TERM") in ("dumb", "emacs"):
391-
return _nullpager(stdout, generator, color)
392-
if (WIN or sys.platform.startswith("os2")) and _tempfilepager(
393-
generator, ["more"], color
394-
):
395-
return
396-
if _pipepager(generator, ["less"], color):
397-
return
398-
399-
import tempfile
400-
401-
fd, filename = tempfile.mkstemp()
402-
os.close(fd)
403-
try:
404-
if _pipepager(generator, ["more"], color):
405-
return
406-
return _nullpager(stdout, generator, color)
407-
finally:
408-
os.unlink(filename)
402+
return _nullpager(stdout, color)
403+
if WIN or sys.platform.startswith("os2"):
404+
return _tempfilepager(["more"], color)
405+
return _pipepager(["less"], color)
406+
407+
408+
@contextlib.contextmanager
409+
def get_pager_file(color: bool | None = None) -> t.Generator[t.IO, None, None]:
410+
"""Context manager.
411+
Yields a writable file-like object which can be used as an output pager.
412+
.. versionadded:: 8.2
413+
:param color: controls if the pager supports ANSI colors or not. The
414+
default is autodetection.
415+
"""
416+
with _pager_contextmanager(color=color) as (stream, encoding, color):
417+
if not getattr(stream, "encoding", None):
418+
# wrap in a text stream
419+
stream = MaybeStripAnsi(stream, color=color, encoding=encoding)
420+
yield stream
421+
stream.flush()
409422

410423

424+
@contextlib.contextmanager
411425
def _pipepager(
412-
generator: cabc.Iterable[str], cmd_parts: list[str], color: bool | None
413-
) -> bool:
426+
cmd_parts: list[str], color: bool | None = None
427+
) -> t.Iterator[t.Tuple[t.BinaryIO, str, bool]]:
414428
"""Page through text by feeding it to another program. Invoking a
415429
pager through this might support colors.
416-
417-
Returns `True` if the command was found, `False` otherwise and thus another
418-
pager should be attempted.
419430
"""
420431
# Split the command into the invoked CLI and its parameters.
421432
if not cmd_parts:
422-
return False
433+
# Return a no-op context manager that yields None
434+
@contextlib.contextmanager
435+
def _noop():
436+
yield None, "", False
437+
return _noop()
423438

424439
import shutil
425440

@@ -428,7 +443,11 @@ def _pipepager(
428443

429444
cmd_filepath = shutil.which(cmd)
430445
if not cmd_filepath:
431-
return False
446+
# Return a no-op context manager
447+
@contextlib.contextmanager
448+
def _noop():
449+
yield None, "", False
450+
return _noop()
432451

433452
# Produces a normalized absolute path string.
434453
# multi-call binaries such as busybox derive their identity from the symlink
@@ -451,6 +470,9 @@ def _pipepager(
451470
elif "r" in less_flags or "R" in less_flags:
452471
color = True
453472

473+
if color is None:
474+
color = False
475+
454476
c = subprocess.Popen(
455477
[str(cmd_path)] + cmd_params,
456478
shell=False,
@@ -459,13 +481,10 @@ def _pipepager(
459481
errors="replace",
460482
text=True,
461483
)
462-
assert c.stdin is not None
484+
stdin = t.cast(t.BinaryIO, c.stdin)
485+
encoding = get_best_encoding(stdin)
463486
try:
464-
for text in generator:
465-
if not color:
466-
text = strip_ansi(text)
467-
468-
c.stdin.write(text)
487+
yield stdin, encoding, color
469488
except BrokenPipeError:
470489
# In case the pager exited unexpectedly, ignore the broken pipe error.
471490
pass
@@ -479,7 +498,7 @@ def _pipepager(
479498
finally:
480499
# We must close stdin and wait for the pager to exit before we continue
481500
try:
482-
c.stdin.close()
501+
stdin.close()
483502
# Close implies flush, so it might throw a BrokenPipeError if the pager
484503
# process exited already.
485504
except BrokenPipeError:
@@ -501,64 +520,54 @@ def _pipepager(
501520
else:
502521
break
503522

504-
return True
505-
506523

524+
@contextlib.contextmanager
507525
def _tempfilepager(
508-
generator: cabc.Iterable[str], cmd_parts: list[str], color: bool | None
509-
) -> bool:
510-
"""Page through text by invoking a program on a temporary file.
511-
512-
Returns `True` if the command was found, `False` otherwise and thus another
513-
pager should be attempted.
514-
"""
526+
cmd_parts: list[str], color: bool | None = None
527+
) -> t.Iterator[t.Tuple[t.BinaryIO, str, bool]]:
528+
"""Page through text by invoking a program on a temporary file."""
515529
# Split the command into the invoked CLI and its parameters.
516530
if not cmd_parts:
517-
return False
531+
# Return a no-op context manager
532+
@contextlib.contextmanager
533+
def _noop():
534+
yield None, "", False
535+
return _noop()
518536

519537
import shutil
538+
import subprocess
520539

521540
cmd = cmd_parts[0]
522541

523542
cmd_filepath = shutil.which(cmd)
524543
if not cmd_filepath:
525-
return False
544+
# Return a no-op context manager
545+
@contextlib.contextmanager
546+
def _noop():
547+
yield None, "", False
548+
return _noop()
549+
526550
# Produces a normalized absolute path string.
527551
# multi-call binaries such as busybox derive their identity from the symlink
528552
# less -> busybox. resolve() causes them to misbehave. (eg. less becomes busybox)
529553
cmd_path = Path(cmd_filepath).absolute()
530554

531-
import subprocess
532555
import tempfile
533556

534-
fd, filename = tempfile.mkstemp()
535-
# TODO: This never terminates if the passed generator never terminates.
536-
text = "".join(generator)
537-
if not color:
538-
text = strip_ansi(text)
539557
encoding = get_best_encoding(sys.stdout)
540-
with open_stream(filename, "wb")[0] as f:
541-
f.write(text.encode(encoding))
542-
try:
543-
subprocess.call([str(cmd_path), filename])
544-
except OSError:
545-
# Command not found
546-
pass
547-
finally:
548-
os.close(fd)
549-
os.unlink(filename)
550-
551-
return True
558+
with tempfile.NamedTemporaryFile(mode="wb") as f:
559+
yield f, encoding, color
560+
f.flush()
561+
subprocess.call([str(cmd_path), f.name])
552562

553563

564+
@contextlib.contextmanager
554565
def _nullpager(
555-
stream: t.TextIO, generator: cabc.Iterable[str], color: bool | None
556-
) -> None:
566+
stream: t.TextIO, color: bool | None = None
567+
) -> t.Iterator[t.Tuple[t.BinaryIO, str, bool]]:
557568
"""Simply print unformatted text. This is the ultimate fallback."""
558-
for text in generator:
559-
if not color:
560-
text = strip_ansi(text)
561-
stream.write(text)
569+
encoding = get_best_encoding(stream)
570+
yield stream, encoding, color
562571

563572

564573
class Editor:

0 commit comments

Comments
 (0)