diff --git a/CHANGELOG.md b/CHANGELOG.md index d18b718..9c4f0bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Changed +- The progress spinner (yaspin) is replaced by a progress bar (tqdm): uploads and downloads show bytes transferred with rate and ETA plus the `done/total` file count; deletes, and downloads whose sizes the listing did not report, count files. + ## [2.0.0] 2026-09-20 First release: a native Python3 port of git-ftp 1.6.0. See [COMPATIBILITY.md](COMPATIBILITY.md) for what is unchanged, what was fixed and what is new. diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 8e31f32..d1bb138 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -85,9 +85,9 @@ fixed rather than reproduced. - `--password` as an alias of `--passwd`; `--no-post-hooks`. - Ctrl-C stops the transfers promptly and exits with 130. - `version -v` prints the libcurl and paramiko versions in use. -- An interactive terminal shows a progress spinner with a `done/total` count - while files upload, delete or download; it renders on stderr and is silent - when output is not a terminal or under `-n`. +- An interactive terminal shows a progress bar (bytes with rate and ETA, plus a + `done/total` file count) while files upload, delete or download; it renders + on stderr and is silent when output is not a terminal or under `-n`. - `--worktree` / `git-ftp.worktree`: `init` and `push` read the upload from a temporary Git worktree checked out at the deployed commit, so edits to the working tree during the upload cannot leak in. Untracked files added by diff --git a/README.md b/README.md index aad55e7..81fe14e 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,8 @@ happen first, then deletes, and the commit log is written last, only when every upload succeeded, so an interrupted deploy never claims a commit it did not finish. Ctrl-C stops promptly. -In an interactive terminal a spinner shows a `done/total` count with the current -file on stderr. It is off when output is piped, in CI, or under `-n`, so scripts +In an interactive terminal a progress bar on stderr shows the bytes transferred +with rate and ETA, plus a `done/total` file count and the current file. It is off when output is piped, in CI, or under `-n`, so scripts see the plain lines unchanged. ### Consistent uploads while editing diff --git a/pyproject.toml b/pyproject.toml index f9809b1..6d3a2ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dependencies = [ "click>=8.2,<9", "pycurl>=7.46,<8", "paramiko>=3.4,<6", - "yaspin>=3.5,<4", + "tqdm>=4.66,<5", ] [project.urls] @@ -59,6 +59,7 @@ dev = [ "mypy>=1.17", "types-paramiko>=3.4", "types-pycurl>=7.45", + "types-tqdm>=4.66", ] [tool.uv] @@ -112,10 +113,6 @@ mypy_path = ["src"] module = ["pyftpdlib", "pyftpdlib.*"] ignore_missing_imports = true -[[tool.mypy.overrides]] -module = ["yaspin", "yaspin.*"] -ignore_missing_imports = true - [[tool.mypy.overrides]] module = ["tests.helpers.ftpserver"] disallow_subclassing_any = false diff --git a/src/gitftp/deploy.py b/src/gitftp/deploy.py index ed4b8fd..9f2425c 100644 --- a/src/gitftp/deploy.py +++ b/src/gitftp/deploy.py @@ -298,8 +298,9 @@ def _sync(self, cs: csmod.ChangeSet) -> None: if uploads and not self.opts.dry_run: out.info("Uploading ...") try: - with Progress(out, "Uploading", len(uploads)) as p: - pool.upload(uploads, on_done=p.advance) + size = sum(u.size for u in uploads) + with Progress(out, "Uploading", len(uploads), size=size) as p: + pool.upload(uploads, on_done=p.advance, on_bytes=p.transferred) except TransferError as e: raise UploadError(f"Could not upload files. {e}") from e for path in cs.deletes: diff --git a/src/gitftp/mirror.py b/src/gitftp/mirror.py index 96110bd..a259eda 100644 --- a/src/gitftp/mirror.py +++ b/src/gitftp/mirror.py @@ -205,8 +205,11 @@ def apply_plan( d.mkdir(parents=True, exist_ok=True) if plan.downloads: try: - with Progress(out, "Downloading", len(plan.downloads)) as prog: - pool.download(plan.downloads, on_done=prog.advance) + sizes = [d.size for d in plan.downloads] + # A byte bar only when the listing reported every size; else count files. + size = None if None in sizes else sum(n or 0 for n in sizes) + with Progress(out, "Downloading", len(plan.downloads), size=size) as prog: + pool.download(plan.downloads, on_done=prog.advance, on_bytes=prog.transferred) except TransferError as e: raise DownloadError(f"Could not download files. {e}") from e out.info(f"Downloaded {len(plan.downloads)} file(s), deleted {deleted} local file(s).") diff --git a/src/gitftp/progress.py b/src/gitftp/progress.py index 26256f2..ede7c5e 100644 --- a/src/gitftp/progress.py +++ b/src/gitftp/progress.py @@ -1,48 +1,98 @@ -"""An interactive transfer spinner (yaspin), rendered on stderr. +"""An interactive transfer progress bar (tqdm), rendered on stderr. -The spinner shows a running `` / `` count while -files transfer. It is active only on an interactive terminal at normal -verbosity, so it never touches the stdout that scripts and the test suite parse, -and it stays silent when output is piped, under ``-n``, or under ``-v``/``-vv`` -(which print their own per-file lines). +When the byte size of a batch is known (uploads, and downloads whose listing +reported every size) the bar tracks bytes with rate and ETA, followed by a +``/ `` count; otherwise (deletes) it counts files. +It is active only on an interactive terminal at normal verbosity, so it never +touches the stdout that scripts and the test suite parse, and it stays silent +when output is piped, under ``-n``, or under ``-v``/``-vv`` (which print their +own per-file lines). """ from __future__ import annotations +import os +import threading from types import TracebackType -from typing import Any +from typing import Any, TextIO from gitftp.output import Level, Output +def _screen_size(stream: TextIO) -> tuple[int, int]: + """Columns and rows of the terminal, 80x24 when it reports none. + + tqdm hides its bar entirely on a terminal that reports 0 rows (bare ptys, + serial consoles), so the size is always passed explicitly. + """ + try: + cols, rows = os.get_terminal_size(stream.fileno()) + except (AttributeError, OSError, ValueError): + cols = rows = 0 + return cols or 80, rows or 24 + + class Progress: - """Context manager wrapping a yaspin spinner; a no-op when not on a TTY.""" + """Context manager wrapping a tqdm bar; a no-op when not on a TTY.""" - def __init__(self, out: Output, verb: str, total: int) -> None: + def __init__(self, out: Output, verb: str, total: int, *, size: int | None = None) -> None: self.out = out self.verb = verb self.total = total + self.size = size or None # total bytes; None (or 0) counts files instead self.done = 0 - self._spinner: Any = None + self._bar: Any = None + self._seen: dict[str, int] = {} # bytes reported so far per in-flight file + self._lock = threading.Lock() # transfer threads report bytes concurrently self.enabled = out.level == Level.NORMAL and out.stderr.isatty() - def _text(self, label: str = "") -> str: - base = f"{self.verb} {self.done}/{self.total}" + def _postfix(self, label: str = "") -> str: + if self.size is None: + return label # the bar itself shows the file count + base = f"{self.done}/{self.total}" return f"{base} {label}" if label else base def __enter__(self) -> Progress: if self.enabled: - from yaspin import yaspin + from tqdm import tqdm - self._spinner = yaspin(text=self._text(), stream=self.out.stderr) - self._spinner.start() + if self.size is None: + kw: dict[str, Any] = {"total": self.total, "unit": "file"} + else: + kw = {"total": self.size, "unit": "B", "unit_scale": True, "unit_divisor": 1024} + ncols, nrows = _screen_size(self.out.stderr) + self._bar = tqdm( + desc=self.verb, + file=self.out.stderr, + leave=False, + ncols=ncols, + nrows=nrows, + postfix=self._postfix() or None, + **kw, + ) return self + def transferred(self, label: str, nbytes: int) -> None: + """Record ``nbytes`` done so far of one file; safe to call from transfer threads.""" + if self._bar is None or self.size is None: + return + with self._lock: + delta = nbytes - self._seen.get(label, 0) # negative when a transfer restarts + self._seen[label] = nbytes + if delta: + self._bar.update(delta) + def advance(self, label: str = "") -> None: - """Count one finished transfer and refresh the spinner text.""" - self.done += 1 - if self._spinner is not None: - self._spinner.text = self._text(label) + """Count one finished transfer and refresh the bar.""" + with self._lock: + self.done += 1 + self._seen.pop(label, None) + if self._bar is not None: + if self.size is None: + self._bar.set_postfix_str(self._postfix(label), refresh=False) + self._bar.update(1) + else: + self._bar.set_postfix_str(self._postfix(label)) def __exit__( self, @@ -50,6 +100,6 @@ def __exit__( exc: BaseException | None, tb: TracebackType | None, ) -> None: - if self._spinner is not None: - self._spinner.stop() # clears its line and restores the cursor - self._spinner = None + if self._bar is not None: + self._bar.close() # leave=False clears its line + self._bar = None diff --git a/src/gitftp/transfer.py b/src/gitftp/transfer.py index 859de06..ee62deb 100644 --- a/src/gitftp/transfer.py +++ b/src/gitftp/transfer.py @@ -17,11 +17,20 @@ from typing import Generic, TypeVar from gitftp.output import Output -from gitftp.transport.base import Connector, TransferCancelled, Transport +from gitftp.transport.base import Connector, ProgressFn, TransferCancelled, Transport T = TypeVar("T") R = TypeVar("R") +BytesFn = Callable[[str, int], None] +"""Called as ``(label, bytes_done)`` while a file transfers, from the worker thread.""" + + +def _bytes_fn(on_bytes: BytesFn | None, label: str) -> ProgressFn | None: + if on_bytes is None: + return None + return lambda done, _total: on_bytes(label, done) + @dataclass(frozen=True) class UploadTask: @@ -247,10 +256,16 @@ def _map_parallel( # -- typed helpers ----------------------------------------------------- def upload( - self, tasks: Sequence[UploadTask], *, on_done: Callable[[str], None] | None = None + self, + tasks: Sequence[UploadTask], + *, + on_done: Callable[[str], None] | None = None, + on_bytes: BytesFn | None = None, ) -> None: def do(t: Transport, task: UploadTask) -> None: - t.put(task.local, task.remote, task.size) + t.put(task.local, task.remote, task.size, progress=_bytes_fn(on_bytes, task.label)) + if on_bytes is not None: + on_bytes(task.label, task.size) # transports may not report the last chunk self.out.debug(f"Uploaded '{task.label}'.") self.map(do, tasks, fail_fast=True, label=lambda task: task.label, on_done=on_done) @@ -268,16 +283,22 @@ def do(t: Transport, task: DeleteTask) -> None: return [r for r in results if isinstance(r, TransferError)] def download( - self, tasks: Sequence[DownloadTask], *, on_done: Callable[[str], None] | None = None + self, + tasks: Sequence[DownloadTask], + *, + on_done: Callable[[str], None] | None = None, + on_bytes: BytesFn | None = None, ) -> None: def do(t: Transport, task: DownloadTask) -> None: part = task.local.with_name(f".{task.local.name}.git-ftp-part") task.local.parent.mkdir(parents=True, exist_ok=True) try: - t.get_file(task.remote, part) + t.get_file(task.remote, part, progress=_bytes_fn(on_bytes, task.label)) if task.mtime is not None: os.utime(part, (task.mtime, task.mtime)) os.replace(part, task.local) + if on_bytes is not None and task.size is not None: + on_bytes(task.label, task.size) except BaseException: with contextlib.suppress(OSError): part.unlink() diff --git a/tests/fakes/memory_transport.py b/tests/fakes/memory_transport.py index 64b020b..e178abe 100644 --- a/tests/fakes/memory_transport.py +++ b/tests/fakes/memory_transport.py @@ -66,6 +66,8 @@ def get(self, path: str) -> bytes: def get_file(self, path: str, local: Path, *, progress: ProgressFn | None = None) -> None: data = self.get(path) local.write_bytes(data) + if progress is not None: + progress(len(data), len(data)) def _enter(self) -> None: with self.store.lock: @@ -79,7 +81,12 @@ def _leave(self) -> None: def put( self, local: Path, remote: str, size: int, *, progress: ProgressFn | None = None ) -> None: - self.put_bytes(local.read_bytes(), remote) + data = local.read_bytes() + if progress is not None: + progress(len(data) // 2, size) + self.put_bytes(data, remote) + if progress is not None: + progress(len(data), size) def put_bytes(self, data: bytes, remote: str) -> None: self._enter() diff --git a/tests/integration/test_entrypoint.py b/tests/integration/test_entrypoint.py index 8217758..05c39f9 100644 --- a/tests/integration/test_entrypoint.py +++ b/tests/integration/test_entrypoint.py @@ -94,16 +94,16 @@ def test_ctrl_c_stops_promptly_and_leaves_log_untouched( @pytest.mark.skipif(sys.platform == "win32", reason="pty is POSIX-only") -def test_progress_spinner_on_a_tty_leaves_stdout_intact( +def test_progress_bar_on_a_tty_leaves_stdout_intact( repo: Repo, ftp_server: FtpServer, cli_bin: list[str] ) -> None: - """With stderr on a real terminal the spinner is active, yet stdout still - carries the exact, parseable messages (the spinner draws only on stderr).""" + """With stderr on a real terminal the progress bar is active, yet stdout still + carries the exact, parseable messages (the bar draws only on stderr).""" import pty import threading s = ftp_server - master, slave = pty.openpty() # child stderr is a TTY -> spinner enabled + master, slave = pty.openpty() # child stderr is a TTY -> progress bar enabled drained: list[bytes] = [] def drain() -> None: @@ -130,10 +130,10 @@ def drain() -> None: stderr = b"".join(drained).decode("utf-8", "replace") assert proc.returncode == 0, stderr - # stdout is unpolluted by the spinner: the upstream lines are exact. + # stdout is unpolluted by the progress bar: the upstream lines are exact. assert "Uploading ..." in proc.stdout assert f"Last deployment changed from to {repo.head()}." in proc.stdout assert "Uploading 0/" not in proc.stdout # progress text never reaches stdout assert s.remote().log() == repo.head() - # The spinner rendered its running count on the terminal (stderr). + # The bar rendered its progress on the terminal (stderr). assert "Uploading" in stderr diff --git a/tests/unit/test_progress.py b/tests/unit/test_progress.py index ed598da..27dbdd2 100644 --- a/tests/unit/test_progress.py +++ b/tests/unit/test_progress.py @@ -1,9 +1,10 @@ from __future__ import annotations import io +import threading from gitftp.output import Level, Output -from gitftp.progress import Progress +from gitftp.progress import Progress, _screen_size class _FakeTTY(io.StringIO): @@ -20,7 +21,7 @@ def test_disabled_when_not_a_tty() -> None: p.advance("a.txt") p.advance("b.txt") assert p.done == 2 # still counts - assert p._spinner is None + assert p._bar is None assert out.stderr.getvalue() == "" # nothing drawn assert out.stdout.getvalue() == "" @@ -31,12 +32,21 @@ def test_disabled_at_verbose_and_silent() -> None: assert not Progress(out, "Uploading", 1).enabled -def test_text_formatting() -> None: +def test_postfix_formatting() -> None: out = Output(Level.NORMAL, stdout=io.StringIO(), stderr=io.StringIO()) - p = Progress(out, "Uploading", 40) - assert p._text() == "Uploading 0/40" + p = Progress(out, "Uploading", 40, size=1000) + assert p._postfix() == "0/40" p.done = 3 - assert p._text("dir/app.js") == "Uploading 3/40 dir/app.js" + assert p._postfix("dir/app.js") == "3/40 dir/app.js" + # Counting files: the bar shows the count itself, the postfix only the file. + assert Progress(out, "Deleting", 40)._postfix("dir/app.js") == "dir/app.js" + + +def test_zero_bytes_counts_files() -> None: + out = Output(Level.NORMAL, stdout=io.StringIO(), stderr=_FakeTTY()) + with Progress(out, "Uploading", 3, size=0) as p: + assert p.size is None + assert p._bar.total == 3 def test_enabled_on_a_tty_draws_only_to_stderr() -> None: @@ -45,9 +55,89 @@ def test_enabled_on_a_tty_draws_only_to_stderr() -> None: p = Progress(out, "Uploading", 2) assert p.enabled with p: - assert p._spinner is not None + assert p._bar is not None p.advance("one.txt") p.advance("two.txt") - assert p._spinner is None # stopped cleanly + assert p._bar.n == 2 + assert p._bar is None # closed cleanly assert p.done == 2 + assert "Uploading" in stderr.getvalue() assert stdout.getvalue() == "" # never touches stdout + + +def test_bytes_mode_counts_deltas() -> None: + out = Output(Level.NORMAL, stdout=io.StringIO(), stderr=_FakeTTY()) + with Progress(out, "Uploading", 2, size=30) as p: + assert p._bar.total == 30 + p.transferred("a", 4) + p.transferred("a", 10) + p.transferred("a", 10) # repeated reports add nothing + p.advance("a") + p.transferred("b", 20) + assert p._bar.n == 30 + p.advance("b") + assert p._bar.n == 30 # finishing a file adds no bytes + assert p._seen == {} + assert p.done == 2 + + +def test_bytes_mode_restarted_transfer_goes_back() -> None: + out = Output(Level.NORMAL, stdout=io.StringIO(), stderr=_FakeTTY()) + with Progress(out, "Uploading", 1, size=10) as p: + p.transferred("a", 8) + p.transferred("a", 3) + assert p._bar.n == 3 + + +def test_bytes_mode_concurrent_reports() -> None: + out = Output(Level.NORMAL, stdout=io.StringIO(), stderr=_FakeTTY()) + files, chunks = 8, 500 + with Progress(out, "Uploading", files, size=files * chunks) as p: + + def worker(label: str) -> None: + for n in range(1, chunks + 1): + p.transferred(label, n) + + threads = [threading.Thread(target=worker, args=(f"f{i}",)) for i in range(files)] + for t in threads: + t.start() + for t in threads: + t.join() + assert p._bar.n == files * chunks + + +def test_transferred_is_a_noop_when_disabled() -> None: + out = Output(Level.NORMAL, stdout=io.StringIO(), stderr=io.StringIO()) + with Progress(out, "Uploading", 1, size=10) as p: + p.transferred("a", 5) + assert p._seen == {} + + +def test_screen_size_falls_back_when_unknown() -> None: + assert _screen_size(io.StringIO()) == (80, 24) + + +def test_draws_on_a_zero_size_pty() -> None: + import os + import pty + import select + + master, slave = pty.openpty() # reports 0x0, on which tqdm would hide the bar + try: + with os.fdopen(slave, "w") as stderr: + out = Output(Level.NORMAL, stdout=io.StringIO(), stderr=stderr) + assert _screen_size(stderr) == (80, 24) + with Progress(out, "Uploading", 2): + pass + drawn = b"" + while select.select([master], [], [], 0.2)[0]: + try: + chunk = os.read(master, 4096) + except OSError: + break + if not chunk: + break + drawn += chunk + finally: + os.close(master) + assert b"Uploading" in drawn diff --git a/tests/unit/test_transfer_pool.py b/tests/unit/test_transfer_pool.py index 3e787b1..523f7ed 100644 --- a/tests/unit/test_transfer_pool.py +++ b/tests/unit/test_transfer_pool.py @@ -1,6 +1,7 @@ from __future__ import annotations import threading +from collections.abc import Callable from pathlib import Path import pytest @@ -127,3 +128,41 @@ def test_cancel_event_shared_with_workers(tmp_path: Path) -> None: threading.Timer(0.02, pool.cancel).start() pool.upload(tasks) # no error: cancelled tasks are skipped, not failed assert len(store.files) < 10 + + +def _byte_recorder() -> tuple[dict[str, list[int]], Callable[[str, int], None]]: + seen: dict[str, list[int]] = {} + lock = threading.Lock() + + def on_bytes(label: str, n: int) -> None: + with lock: + seen.setdefault(label, []).append(n) + + return seen, on_bytes + + +@pytest.mark.parametrize("jobs", [1, 4]) +def test_upload_reports_bytes(tmp_path: Path, jobs: int) -> None: + store = MemoryStore() + tasks = make_tasks(tmp_path, 12) + seen, on_bytes = _byte_recorder() + with TransferPool(store.connect, jobs, Output(Level.SILENT)) as pool: + pool.upload(tasks, on_bytes=on_bytes) + assert set(seen) == {t.label for t in tasks} + for t in tasks: + assert seen[t.label][0] == t.size // 2 # the transport's own chunk reports + assert seen[t.label][-1] == t.size # always ends at the full size + + +def test_download_reports_bytes(tmp_path: Path) -> None: + store = MemoryStore() + store.files["a.txt"] = b"hello" + store.files["b.txt"] = b"hi" + tasks = [ + DownloadTask(remote="a.txt", local=tmp_path / "a.txt", size=5, mtime=None, label="a"), + DownloadTask(remote="b.txt", local=tmp_path / "b.txt", size=None, mtime=None, label="b"), + ] + seen, on_bytes = _byte_recorder() + with TransferPool(store.connect, 1, Output(Level.SILENT)) as pool: + pool.download(tasks, on_bytes=on_bytes) + assert seen == {"a": [5, 5], "b": [2]} # no size top-up when the size is unknown diff --git a/uv.lock b/uv.lock index 97fa2a6..bb74050 100644 --- a/uv.lock +++ b/uv.lock @@ -491,7 +491,7 @@ dependencies = [ { name = "click" }, { name = "paramiko" }, { name = "pycurl" }, - { name = "yaspin" }, + { name = "tqdm" }, ] [package.dev-dependencies] @@ -506,6 +506,7 @@ dev = [ { name = "ruff" }, { name = "types-paramiko" }, { name = "types-pycurl" }, + { name = "types-tqdm" }, ] [package.metadata] @@ -513,7 +514,7 @@ requires-dist = [ { name = "click", specifier = ">=8.2,<9" }, { name = "paramiko", specifier = ">=3.4,<6" }, { name = "pycurl", specifier = ">=7.46,<8" }, - { name = "yaspin", specifier = ">=3.5,<4" }, + { name = "tqdm", specifier = ">=4.66,<5" }, ] [package.metadata.requires-dev] @@ -528,6 +529,7 @@ dev = [ { name = "ruff", specifier = ">=0.12" }, { name = "types-paramiko", specifier = ">=3.4" }, { name = "types-pycurl", specifier = ">=7.45" }, + { name = "types-tqdm", specifier = ">=4.66" }, ] [[package]] @@ -1023,15 +1025,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/71/eb4f0ccc844aece56963e8578df9c95d4c00f580547d52329d4035d3af18/ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88", size = 9872035, upload-time = "2026-10-09T19:47:26.306Z" }, ] -[[package]] -name = "termcolor" -version = "3.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, -] - [[package]] name = "tomli" version = "2.4.1" @@ -1086,6 +1079,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "tqdm" +version = "4.70.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/ea/b2a5bd54b28a324dae8211928b2d730b6547500342c7e6c6dea08bd0a485/tqdm-4.70.1.tar.gz", hash = "sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4", size = 171846, upload-time = "2026-09-11T07:25:16.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73", size = 80199, upload-time = "2026-09-11T07:25:14.599Z" }, +] + [[package]] name = "types-paramiko" version = "5.0.0.20261006" @@ -1108,22 +1113,19 @@ wheels = [ ] [[package]] -name = "typing-extensions" -version = "4.16.0" +name = "types-tqdm" +version = "4.70.0.20260906" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/92/517d71d5a399cd2f8ead3b4328d987262478f22fbbe7f8312360d4c321d0/types_tqdm-4.70.0.20260906.tar.gz", hash = "sha256:6581dc996152a87d51ce2ea169169187ffc0a96dacdb8adbdef29cbee8c1e631", size = 19309, upload-time = "2026-09-06T06:35:31.916Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, + { url = "https://files.pythonhosted.org/packages/81/ea/d12697988ab2c672877796ecaea69666e2379ee7b40f01f4540250d5a97b/types_tqdm-4.70.0.20260906-py3-none-any.whl", hash = "sha256:c3b88c694928515d601f73fa6646aa43adc0ef735bedbeebf0cc543f6a201ae8", size = 25181, upload-time = "2026-09-06T06:35:30.931Z" }, ] [[package]] -name = "yaspin" -version = "3.5.1" +name = "typing-extensions" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "termcolor" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/37/20/4b44e31e5a1bfe09624267656dc02c31fe920e35e81697722dd45caac9f1/yaspin-3.5.1.tar.gz", hash = "sha256:5cb55fb774eae3543fdd30b5958c46e348dfd316a8b289c9f8db2c6f8a511846", size = 44488, upload-time = "2026-09-18T19:05:39.82Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/54/74737771bfc9b945b7e11968100c096fc2d1af075644e43d5fa51ab773a7/yaspin-3.5.1-py3-none-any.whl", hash = "sha256:193ef981f0796a93f77d91221dd19bd2c83b0f6a26f43edbc0507c457c54c1cb", size = 22875, upload-time = "2026-09-18T19:05:38.334Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ]