Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes15kdownloads
_termui_impl.py871 linesDownload Raw Back to click
1"""2This module contains implementations for the termui module. To keep the3import time of Click down, some infrequently used functionality is4placed in this module and only imported as needed.5"""6 7from __future__ import annotations8 9import collections.abc as cabc10import contextlib11import math12import os13import shlex14import sys15import time16import typing as t17from gettext import gettext as _18from io import StringIO19from pathlib import Path20from types import TracebackType21 22from ._compat import _default_text_stdout23from ._compat import CYGWIN24from ._compat import get_best_encoding25from ._compat import isatty26from ._compat import open_stream27from ._compat import strip_ansi28from ._compat import term_len29from ._compat import WIN30from .exceptions import ClickException31from .utils import echo32 33V = t.TypeVar("V")34 35if os.name == "nt":36    BEFORE_BAR = "\r"37    AFTER_BAR = "\n"38else:39    BEFORE_BAR = "\r\033[?25l"40    AFTER_BAR = "\033[?25h\n"41 42 43class ProgressBar(t.Generic[V]):44    def __init__(45        self,46        iterable: cabc.Iterable[V] | None,47        length: int | None = None,48        fill_char: str = "#",49        empty_char: str = " ",50        bar_template: str = "%(bar)s",51        info_sep: str = "  ",52        hidden: bool = False,53        show_eta: bool = True,54        show_percent: bool | None = None,55        show_pos: bool = False,56        item_show_func: t.Callable[[V | None], str | None] | None = None,57        label: str | None = None,58        file: t.TextIO | None = None,59        color: bool | None = None,60        update_min_steps: int = 1,61        width: int = 30,62    ) -> None:63        self.fill_char = fill_char64        self.empty_char = empty_char65        self.bar_template = bar_template66        self.info_sep = info_sep67        self.hidden = hidden68        self.show_eta = show_eta69        self.show_percent = show_percent70        self.show_pos = show_pos71        self.item_show_func = item_show_func72        self.label: str = label or ""73 74        if file is None:75            file = _default_text_stdout()76 77            # There are no standard streams attached to write to. For example,78            # pythonw on Windows.79            if file is None:80                file = StringIO()81 82        self.file = file83        self.color = color84        self.update_min_steps = update_min_steps85        self._completed_intervals = 086        self.width: int = width87        self.autowidth: bool = width == 088 89        if length is None:90            from operator import length_hint91 92            length = length_hint(iterable, -1)93 94            if length == -1:95                length = None96        if iterable is None:97            if length is None:98                raise TypeError("iterable or length is required")99            iterable = t.cast("cabc.Iterable[V]", range(length))100        self.iter: cabc.Iterable[V] = iter(iterable)101        self.length = length102        self.pos: int = 0103        self.avg: list[float] = []104        self.last_eta: float105        self.start: float106        self.start = self.last_eta = time.time()107        self.eta_known: bool = False108        self.finished: bool = False109        self.max_width: int | None = None110        self.entered: bool = False111        self.current_item: V | None = None112        self._is_atty = isatty(self.file)113        self._last_line: str | None = None114 115    def __enter__(self) -> ProgressBar[V]:116        self.entered = True117        self.render_progress()118        return self119 120    def __exit__(121        self,122        exc_type: type[BaseException] | None,123        exc_value: BaseException | None,124        tb: TracebackType | None,125    ) -> None:126        self.render_finish()127 128    def __iter__(self) -> cabc.Iterator[V]:129        if not self.entered:130            raise RuntimeError("You need to use progress bars in a with block.")131        self.render_progress()132        return self.generator()133 134    def __next__(self) -> V:135        # Iteration is defined in terms of a generator function,136        # returned by iter(self); use that to define next(). This works137        # because `self.iter` is an iterable consumed by that generator,138        # so it is re-entry safe. Calling `next(self.generator())`139        # twice works and does "what you want".140        return next(iter(self))141 142    def render_finish(self) -> None:143        if self.hidden or not self._is_atty:144            return145        self.file.write(AFTER_BAR)146        self.file.flush()147 148    @property149    def pct(self) -> float:150        if self.finished:151            return 1.0152        return min(self.pos / (float(self.length or 1) or 1), 1.0)153 154    @property155    def time_per_iteration(self) -> float:156        if not self.avg:157            return 0.0158        return sum(self.avg) / float(len(self.avg))159 160    @property161    def eta(self) -> float:162        if self.length is not None and not self.finished:163            return self.time_per_iteration * (self.length - self.pos)164        return 0.0165 166    def format_eta(self) -> str:167        if self.eta_known:168            t = int(self.eta)169            seconds = t % 60170            t //= 60171            minutes = t % 60172            t //= 60173            hours = t % 24174            t //= 24175            if t > 0:176                return f"{t}d {hours:02}:{minutes:02}:{seconds:02}"177            else:178                return f"{hours:02}:{minutes:02}:{seconds:02}"179        return ""180 181    def format_pos(self) -> str:182        pos = str(self.pos)183        if self.length is not None:184            pos += f"/{self.length}"185        return pos186 187    def format_pct(self) -> str:188        return f"{int(self.pct * 100): 4}%"[1:]189 190    def format_bar(self) -> str:191        if self.length is not None:192            bar_length = int(self.pct * self.width)193            bar = self.fill_char * bar_length194            bar += self.empty_char * (self.width - bar_length)195        elif self.finished:196            bar = self.fill_char * self.width197        else:198            chars = list(self.empty_char * (self.width or 1))199            if self.time_per_iteration != 0:200                chars[201                    int(202                        (math.cos(self.pos * self.time_per_iteration) / 2.0 + 0.5)203                        * self.width204                    )205                ] = self.fill_char206            bar = "".join(chars)207        return bar208 209    def format_progress_line(self) -> str:210        show_percent = self.show_percent211 212        info_bits = []213        if self.length is not None and show_percent is None:214            show_percent = not self.show_pos215 216        if self.show_pos:217            info_bits.append(self.format_pos())218        if show_percent:219            info_bits.append(self.format_pct())220        if self.show_eta and self.eta_known and not self.finished:221            info_bits.append(self.format_eta())222        if self.item_show_func is not None:223            item_info = self.item_show_func(self.current_item)224            if item_info is not None:225                info_bits.append(item_info)226 227        return (228            self.bar_template229            % {230                "label": self.label,231                "bar": self.format_bar(),232                "info": self.info_sep.join(info_bits),233            }234        ).rstrip()235 236    def render_progress(self) -> None:237        if self.hidden:238            return239 240        if not self._is_atty:241            # Only output the label once if the output is not a TTY.242            if self._last_line != self.label:243                self._last_line = self.label244                echo(self.label, file=self.file, color=self.color)245            return246 247        buf = []248        # Update width in case the terminal has been resized249        if self.autowidth:250            import shutil251 252            old_width = self.width253            self.width = 0254            clutter_length = term_len(self.format_progress_line())255            new_width = max(0, shutil.get_terminal_size().columns - clutter_length)256            if new_width < old_width and self.max_width is not None:257                buf.append(BEFORE_BAR)258                buf.append(" " * self.max_width)259                self.max_width = new_width260            self.width = new_width261 262        clear_width = self.width263        if self.max_width is not None:264            clear_width = self.max_width265 266        buf.append(BEFORE_BAR)267        line = self.format_progress_line()268        line_len = term_len(line)269        if self.max_width is None or self.max_width < line_len:270            self.max_width = line_len271 272        buf.append(line)273        buf.append(" " * (clear_width - line_len))274        line = "".join(buf)275        # Render the line only if it changed.276 277        if line != self._last_line:278            self._last_line = line279            echo(line, file=self.file, color=self.color, nl=False)280            self.file.flush()281 282    def make_step(self, n_steps: int) -> None:283        self.pos += n_steps284        if self.length is not None and self.pos >= self.length:285            self.finished = True286 287        if (time.time() - self.last_eta) < 1.0:288            return289 290        self.last_eta = time.time()291 292        # self.avg is a rolling list of length <= 7 of steps where steps are293        # defined as time elapsed divided by the total progress through294        # self.length.295        if self.pos:296            step = (time.time() - self.start) / self.pos297        else:298            step = time.time() - self.start299 300        self.avg = self.avg[-6:] + [step]301 302        self.eta_known = self.length is not None303 304    def update(self, n_steps: int, current_item: V | None = None) -> None:305        """Update the progress bar by advancing a specified number of306        steps, and optionally set the ``current_item`` for this new307        position.308 309        :param n_steps: Number of steps to advance.310        :param current_item: Optional item to set as ``current_item``311            for the updated position.312 313        .. versionchanged:: 8.0314            Added the ``current_item`` optional parameter.315 316        .. versionchanged:: 8.0317            Only render when the number of steps meets the318            ``update_min_steps`` threshold.319        """320        if current_item is not None:321            self.current_item = current_item322 323        self._completed_intervals += n_steps324 325        if self._completed_intervals >= self.update_min_steps:326            self.make_step(self._completed_intervals)327            self.render_progress()328            self._completed_intervals = 0329 330    def finish(self) -> None:331        self.eta_known = False332        self.current_item = None333        self.finished = True334 335    def generator(self) -> cabc.Iterator[V]:336        """Return a generator which yields the items added to the bar337        during construction, and updates the progress bar *after* the338        yielded block returns.339        """340        # WARNING: the iterator interface for `ProgressBar` relies on341        # this and only works because this is a simple generator which342        # doesn't create or manage additional state. If this function343        # changes, the impact should be evaluated both against344        # `iter(bar)` and `next(bar)`. `next()` in particular may call345        # `self.generator()` repeatedly, and this must remain safe in346        # order for that interface to work.347        if not self.entered:348            raise RuntimeError("You need to use progress bars in a with block.")349 350        if not self._is_atty:351            yield from self.iter352        else:353            for rv in self.iter:354                self.current_item = rv355 356                # This allows show_item_func to be updated before the357                # item is processed. Only trigger at the beginning of358                # the update interval.359                if self._completed_intervals == 0:360                    self.render_progress()361 362                yield rv363                self.update(1)364 365            self.finish()366            self.render_progress()367 368 369def pager(generator: cabc.Iterable[str], color: bool | None = None) -> None:370    """Decide what method to use for paging through text."""371    stdout = _default_text_stdout()372 373    # There are no standard streams attached to write to. For example,374    # pythonw on Windows.375    if stdout is None:376        stdout = StringIO()377 378    if not isatty(sys.stdin) or not isatty(stdout):379        return _nullpager(stdout, generator, color)380 381    # Split using POSIX mode (the default) so that quote characters are382    # stripped from tokens and quoted Windows paths are preserved.383    # Non-POSIX mode retains quotes in tokens, and wrapping tokens384    # with shlex.quote re-introduces quoting issues on Windows.385    pager_cmd_parts = shlex.split(os.environ.get("PAGER", ""))386    if pager_cmd_parts:387        if WIN:388            if _tempfilepager(generator, pager_cmd_parts, color):389                return390        elif _pipepager(generator, pager_cmd_parts, color):391            return392 393    if os.environ.get("TERM") in ("dumb", "emacs"):394        return _nullpager(stdout, generator, color)395    if (WIN or sys.platform.startswith("os2")) and _tempfilepager(396        generator, ["more"], color397    ):398        return399    if _pipepager(generator, ["less"], color):400        return401 402    import tempfile403 404    fd, filename = tempfile.mkstemp()405    os.close(fd)406    try:407        if _pipepager(generator, ["more"], color):408            return409        return _nullpager(stdout, generator, color)410    finally:411        os.unlink(filename)412 413 414def _pipepager(415    generator: cabc.Iterable[str], cmd_parts: list[str], color: bool | None416) -> bool:417    """Page through text by feeding it to another program.418 419    Invokes the pager via :class:`subprocess.Popen` with an ``argv`` list420    produced by :func:`shlex.split`. The command is resolved to an absolute421    path with :func:`shutil.which` as recommended by the422    :mod:`subprocess` docs for Windows compatibility.423 424    Invoking a pager through this might support colors: if piping to425    ``less`` and the user hasn't decided on colors, ``LESS=-R`` is set426    automatically.427 428    Returns ``True`` if the command was found and executed, ``False``429    otherwise so another pager can be attempted.430    """431    # Split the command into the invoked CLI and its parameters.432    if not cmd_parts:433        return False434 435    import shutil436 437    cmd = cmd_parts[0]438    cmd_params = cmd_parts[1:]439 440    cmd_filepath = shutil.which(cmd)441    if not cmd_filepath:442        return False443 444    # Produces a normalized absolute path string.445    # multi-call binaries such as busybox derive their identity from the symlink446    # less -> busybox. resolve() causes them to misbehave. (eg. less becomes busybox)447    cmd_path = Path(cmd_filepath).absolute()448    cmd_name = cmd_path.name449 450    import subprocess451 452    # Make a local copy of the environment to not affect the global one.453    env = dict(os.environ)454 455    # If we're piping to less and the user hasn't decided on colors, we enable456    # them by default we find the -R flag in the command line arguments.457    if color is None and cmd_name == "less":458        less_flags = f"{os.environ.get('LESS', '')}{' '.join(cmd_params)}"459        if not less_flags:460            env["LESS"] = "-R"461            color = True462        elif "r" in less_flags or "R" in less_flags:463            color = True464 465    c = subprocess.Popen(466        [str(cmd_path)] + cmd_params,467        shell=False,468        stdin=subprocess.PIPE,469        env=env,470        errors="replace",471        text=True,472    )473    assert c.stdin is not None474    try:475        for text in generator:476            if not color:477                text = strip_ansi(text)478 479            c.stdin.write(text)480    except BrokenPipeError:481        # In case the pager exited unexpectedly, ignore the broken pipe error.482        pass483    except Exception as e:484        # In case there is an exception we want to close the pager immediately485        # and let the caller handle it.486        # Otherwise the pager will keep running, and the user may not notice487        # the error message, or worse yet it may leave the terminal in a broken state.488        c.terminate()489        raise e490    finally:491        # We must close stdin and wait for the pager to exit before we continue492        try:493            c.stdin.close()494        # Close implies flush, so it might throw a BrokenPipeError if the pager495        # process exited already.496        except BrokenPipeError:497            pass498 499        # Less doesn't respect ^C, but catches it for its own UI purposes (aborting500        # search or other commands inside less).501        #502        # That means when the user hits ^C, the parent process (click) terminates,503        # but less is still alive, paging the output and messing up the terminal.504        #505        # If the user wants to make the pager exit on ^C, they should set506        # `LESS='-K'`. It's not our decision to make.507        while True:508            try:509                c.wait()510            except KeyboardInterrupt:511                pass512            else:513                break514 515    return True516 517 518def _tempfilepager(519    generator: cabc.Iterable[str], cmd_parts: list[str], color: bool | None520) -> bool:521    """Page through text by invoking a program on a temporary file.522 523    Used as the primary pager strategy on Windows (where piping to524    ``more`` adds spurious ``\\r\\n``), and as a fallback on other525    platforms. The command is resolved to an absolute path with526    :func:`shutil.which`.527 528    Returns ``True`` if the command was found and executed, ``False``529    otherwise so another pager can be attempted.530    """531    # Split the command into the invoked CLI and its parameters.532    if not cmd_parts:533        return False534 535    import shutil536 537    cmd = cmd_parts[0]538 539    cmd_filepath = shutil.which(cmd)540    if not cmd_filepath:541        return False542    # Produces a normalized absolute path string.543    # multi-call binaries such as busybox derive their identity from the symlink544    # less -> busybox. resolve() causes them to misbehave. (eg. less becomes busybox)545    cmd_path = Path(cmd_filepath).absolute()546 547    import subprocess548    import tempfile549 550    fd, filename = tempfile.mkstemp()551    # TODO: This never terminates if the passed generator never terminates.552    text = "".join(generator)553    if not color:554        text = strip_ansi(text)555    encoding = get_best_encoding(sys.stdout)556    with open_stream(filename, "wb")[0] as f:557        f.write(text.encode(encoding))558    try:559        subprocess.call([str(cmd_path), filename])560    except OSError:561        # Command not found562        pass563    finally:564        os.close(fd)565        os.unlink(filename)566 567    return True568 569 570def _nullpager(571    stream: t.TextIO, generator: cabc.Iterable[str], color: bool | None572) -> None:573    """Simply print unformatted text.  This is the ultimate fallback."""574    for text in generator:575        if not color:576            text = strip_ansi(text)577        stream.write(text)578 579 580class Editor:581    def __init__(582        self,583        editor: str | None = None,584        env: cabc.Mapping[str, str] | None = None,585        require_save: bool = True,586        extension: str = ".txt",587    ) -> None:588        self.editor = editor589        self.env = env590        self.require_save = require_save591        self.extension = extension592 593    def get_editor(self) -> str:594        if self.editor is not None:595            return self.editor596        for key in "VISUAL", "EDITOR":597            rv = os.environ.get(key)598            if rv:599                return rv600        if WIN:601            return "notepad"602 603        from shutil import which604 605        for editor in "sensible-editor", "vim", "nano":606            if which(editor) is not None:607                return editor608        return "vi"609 610    def edit_files(self, filenames: cabc.Iterable[str]) -> None:611        """Open files in the user's editor."""612        import shlex613        import subprocess614 615        editor = self.get_editor()616        environ: dict[str, str] | None = None617 618        if self.env:619            environ = os.environ.copy()620            environ.update(self.env)621 622        try:623            # Split in POSIX mode (the default) for the same reasons as624            # in pager(): strips quotes from tokens and preserves quoted625            # Windows paths.626            c = subprocess.Popen(627                args=shlex.split(editor) + list(filenames),628                env=environ,629            )630            exit_code = c.wait()631            if exit_code != 0:632                raise ClickException(633                    _("{editor}: Editing failed").format(editor=editor)634                )635        except OSError as e:636            raise ClickException(637                _("{editor}: Editing failed: {e}").format(editor=editor, e=e)638            ) from e639 640    @t.overload641    def edit(self, text: bytes | bytearray) -> bytes | None: ...642 643    # We cannot know whether or not the type expected is str or bytes when None644    # is passed, so str is returned as that was what was done before.645    @t.overload646    def edit(self, text: str | None) -> str | None: ...647 648    def edit(self, text: str | bytes | bytearray | None) -> str | bytes | None:649        import tempfile650 651        if text is None:652            data: bytes | bytearray = b""653        elif isinstance(text, (bytes, bytearray)):654            data = text655        else:656            if text and not text.endswith("\n"):657                text += "\n"658 659            if WIN:660                data = text.replace("\n", "\r\n").encode("utf-8-sig")661            else:662                data = text.encode("utf-8")663 664        fd, name = tempfile.mkstemp(prefix="editor-", suffix=self.extension)665        f: t.BinaryIO666 667        try:668            with os.fdopen(fd, "wb") as f:669                f.write(data)670 671            # If the filesystem resolution is 1 second, like Mac OS672            # 10.12 Extended, or 2 seconds, like FAT32, and the editor673            # closes very fast, require_save can fail. Set the modified674            # time to be 2 seconds in the past to work around this.675            os.utime(name, (os.path.getatime(name), os.path.getmtime(name) - 2))676            # Depending on the resolution, the exact value might not be677            # recorded, so get the new recorded value.678            timestamp = os.path.getmtime(name)679 680            self.edit_files((name,))681 682            if self.require_save and os.path.getmtime(name) == timestamp:683                return None684 685            with open(name, "rb") as f:686                rv = f.read()687 688            if isinstance(text, (bytes, bytearray)):689                return rv690 691            return rv.decode("utf-8-sig").replace("\r\n", "\n")692        finally:693            os.unlink(name)694 695 696def open_url(url: str, wait: bool = False, locate: bool = False) -> int:697    import subprocess698 699    def _unquote_file(url: str) -> str:700        from urllib.parse import unquote701 702        if url.startswith("file://"):703            url = unquote(url[7:])704 705        return url706 707    if sys.platform == "darwin":708        args = ["open"]709        if wait:710            args.append("-W")711        if locate:712            args.append("-R")713        args.append(_unquote_file(url))714        null = open("/dev/null", "w")715        try:716            return subprocess.Popen(args, stderr=null).wait()717        finally:718            null.close()719    elif WIN:720        if locate:721            url = _unquote_file(url)722            args = ["explorer", f"/select,{url}"]723        else:724            args = ["start"]725            if wait:726                args.append("/WAIT")727            args.append("")728            args.append(url)729        try:730            return subprocess.call(args)731        except OSError:732            # Command not found733            return 127734    elif CYGWIN:735        if locate:736            url = _unquote_file(url)737            args = ["cygstart", os.path.dirname(url)]738        else:739            args = ["cygstart"]740            if wait:741                args.append("-w")742            args.append(url)743        try:744            return subprocess.call(args)745        except OSError:746            # Command not found747            return 127748 749    try:750        if locate:751            url = os.path.dirname(_unquote_file(url)) or "."752        else:753            url = _unquote_file(url)754        c = subprocess.Popen(["xdg-open", url])755        if wait:756            return c.wait()757        return 0758    except OSError:759        if url.startswith(("http://", "https://")) and not locate and not wait:760            import webbrowser761 762            webbrowser.open(url)763            return 0764        return 1765 766 767def _translate_ch_to_exc(ch: str) -> None:768    if ch == "\x03":769        raise KeyboardInterrupt()770 771    if ch == "\x04" and not WIN:  # Unix-like, Ctrl+D772        raise EOFError()773 774    if ch == "\x1a" and WIN:  # Windows, Ctrl+Z775        raise EOFError()776 777 778if sys.platform == "win32":779    import msvcrt780 781    @contextlib.contextmanager782    def raw_terminal() -> cabc.Iterator[int]:783        yield -1784 785    def getchar(echo: bool) -> str:786        # The function `getch` will return a bytes object corresponding to787        # the pressed character. Since Windows 10 build 1803, it will also788        # return \x00 when called a second time after pressing a regular key.789        #790        # `getwch` does not share this probably-bugged behavior. Moreover, it791        # returns a Unicode object by default, which is what we want.792        #793        # Either of these functions will return \x00 or \xe0 to indicate794        # a special key, and you need to call the same function again to get795        # the "rest" of the code. The fun part is that \u00e0 is796        # "latin small letter a with grave", so if you type that on a French797        # keyboard, you _also_ get a \xe0.798        # E.g., consider the Up arrow. This returns \xe0 and then \x48. The799        # resulting Unicode string reads as "a with grave" + "capital H".800        # This is indistinguishable from when the user actually types801        # "a with grave" and then "capital H".802        #803        # When \xe0 is returned, we assume it's part of a special-key sequence804        # and call `getwch` again, but that means that when the user types805        # the \u00e0 character, `getchar` doesn't return until a second806        # character is typed.807        # The alternative is returning immediately, but that would mess up808        # cross-platform handling of arrow keys and others that start with809        # \xe0. Another option is using `getch`, but then we can't reliably810        # read non-ASCII characters, because return values of `getch` are811        # limited to the current 8-bit codepage.812        #813        # Anyway, Click doesn't claim to do this Right(tm), and using `getwch`814        # is doing the right thing in more situations than with `getch`.815 816        if echo:817            func = t.cast(t.Callable[[], str], msvcrt.getwche)818        else:819            func = t.cast(t.Callable[[], str], msvcrt.getwch)820 821        rv = func()822 823        if rv in ("\x00", "\xe0"):824            # \x00 and \xe0 are control characters that indicate special key,825            # see above.826            rv += func()827 828        _translate_ch_to_exc(rv)829        return rv830 831else:832    import termios833    import tty834 835    @contextlib.contextmanager836    def raw_terminal() -> cabc.Iterator[int]:837        f: t.TextIO | None838        fd: int839 840        if not isatty(sys.stdin):841            f = open("/dev/tty")842            fd = f.fileno()843        else:844            fd = sys.stdin.fileno()845            f = None846 847        try:848            old_settings = termios.tcgetattr(fd)849 850            try:851                tty.setraw(fd)852                yield fd853            finally:854                termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)855                sys.stdout.flush()856 857                if f is not None:858                    f.close()859        except termios.error:860            pass861 862    def getchar(echo: bool) -> str:863        with raw_terminal() as fd:864            ch = os.read(fd, 32).decode(get_best_encoding(sys.stdin), "replace")865 866            if echo and isatty(sys.stdout):867                sys.stdout.write(ch)868 869            _translate_ch_to_exc(ch)870            return ch871 
codekingpro/portable-devtools · Team Ai