From c07b9e65a65d4fd7b5b778689b597f9a57edcc5f Mon Sep 17 00:00:00 2001 From: Rene Moser Date: Sun, 13 Sep 2026 19:34:57 +0200 Subject: [PATCH 1/2] add spinner and progress --- COMPATIBILITY.md | 3 ++ README.md | 4 ++ pyproject.toml | 5 +++ src/gitftp/deploy.py | 7 +++- src/gitftp/mirror.py | 4 +- src/gitftp/progress.py | 55 ++++++++++++++++++++++++++++ src/gitftp/transfer.py | 36 +++++++++++++----- tests/integration/test_entrypoint.py | 46 +++++++++++++++++++++++ tests/integration/test_push.py | 4 +- tests/unit/test_progress.py | 53 +++++++++++++++++++++++++++ uv.lock | 23 ++++++++++++ 11 files changed, 226 insertions(+), 14 deletions(-) create mode 100644 src/gitftp/progress.py create mode 100644 tests/unit/test_progress.py diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index a6cccb0..8e31f32 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -85,6 +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`. - `--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 9a26e46..f7655a6 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,10 @@ 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 +see the plain lines unchanged. + ### Consistent uploads while editing `--worktree`, or `git config git-ftp.worktree true`, reads the files to upload diff --git a/pyproject.toml b/pyproject.toml index d42e8a1..bb37bb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "click>=8.2,<9", "pycurl>=7.46,<8", "paramiko>=3.4,<6", + "yaspin>=3.5,<4", ] [project.urls] @@ -111,6 +112,10 @@ 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 22505cc..ed4b8fd 100644 --- a/src/gitftp/deploy.py +++ b/src/gitftp/deploy.py @@ -20,6 +20,7 @@ from gitftp.hooks import POST_PUSH, PRE_PUSH, run_hook from gitftp.lock import RemoteLock from gitftp.options import CliOptions +from gitftp.progress import Progress from gitftp.session import Session from gitftp.transfer import DeleteTask, TransferError, TransferPool, UploadTask from gitftp.transport import registry @@ -297,7 +298,8 @@ def _sync(self, cs: csmod.ChangeSet) -> None: if uploads and not self.opts.dry_run: out.info("Uploading ...") try: - pool.upload(uploads) + with Progress(out, "Uploading", len(uploads)) as p: + pool.upload(uploads, on_done=p.advance) except TransferError as e: raise UploadError(f"Could not upload files. {e}") from e for path in cs.deletes: @@ -306,7 +308,8 @@ def _sync(self, cs: csmod.ChangeSet) -> None: deletes.append(DeleteTask(remote=csmod.remote_path(path, s.syncroot), label=path)) if deletes and not self.opts.dry_run: out.info("Deleting ...") - errors = pool.delete(deletes) + with Progress(out, "Deleting", len(deletes)) as p: + errors = pool.delete(deletes, on_done=p.advance) for err in errors: out.debug(f"Could not delete {err.label}, continuing... ({err.cause})") if errors: diff --git a/src/gitftp/mirror.py b/src/gitftp/mirror.py index 2630612..96110bd 100644 --- a/src/gitftp/mirror.py +++ b/src/gitftp/mirror.py @@ -15,6 +15,7 @@ from gitftp.lock import LOCK_FILE, RemoteLock from gitftp.options import CliOptions from gitftp.output import Output +from gitftp.progress import Progress from gitftp.session import Session, open_session from gitftp.transfer import DownloadTask, TransferError, TransferPool from gitftp.transport import registry @@ -204,7 +205,8 @@ def apply_plan( d.mkdir(parents=True, exist_ok=True) if plan.downloads: try: - pool.download(plan.downloads) + with Progress(out, "Downloading", len(plan.downloads)) as prog: + pool.download(plan.downloads, on_done=prog.advance) 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 new file mode 100644 index 0000000..26256f2 --- /dev/null +++ b/src/gitftp/progress.py @@ -0,0 +1,55 @@ +"""An interactive transfer spinner (yaspin), 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). +""" + +from __future__ import annotations + +from types import TracebackType +from typing import Any + +from gitftp.output import Level, Output + + +class Progress: + """Context manager wrapping a yaspin spinner; a no-op when not on a TTY.""" + + def __init__(self, out: Output, verb: str, total: int) -> None: + self.out = out + self.verb = verb + self.total = total + self.done = 0 + self._spinner: Any = None + self.enabled = out.level == Level.NORMAL and out.stderr.isatty() + + def _text(self, label: str = "") -> str: + base = f"{self.verb} {self.done}/{self.total}" + return f"{base} {label}" if label else base + + def __enter__(self) -> Progress: + if self.enabled: + from yaspin import yaspin + + self._spinner = yaspin(text=self._text(), stream=self.out.stderr) + self._spinner.start() + return self + + 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) + + def __exit__( + self, + exc_type: type[BaseException] | None, + 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 diff --git a/src/gitftp/transfer.py b/src/gitftp/transfer.py index de72a42..859de06 100644 --- a/src/gitftp/transfer.py +++ b/src/gitftp/transfer.py @@ -152,12 +152,13 @@ def map( *, fail_fast: bool = True, label: Callable[[T], str] = str, + on_done: Callable[[str], None] | None = None, ) -> list[R | TransferError | _Skipped]: if not items: return [] if self.jobs == 1 or len(items) == 1: - return self._map_serial(fn, items, fail_fast=fail_fast, label=label) - return self._map_parallel(fn, items, fail_fast=fail_fast, label=label) + return self._map_serial(fn, items, fail_fast=fail_fast, label=label, on_done=on_done) + return self._map_parallel(fn, items, fail_fast=fail_fast, label=label, on_done=on_done) def _map_serial( self, @@ -166,6 +167,7 @@ def _map_serial( *, fail_fast: bool, label: Callable[[T], str], + on_done: Callable[[str], None] | None = None, ) -> list[R | TransferError | _Skipped]: results: list[R | TransferError | _Skipped] = [] t = self._primary() @@ -182,6 +184,9 @@ def _map_serial( if fail_fast: raise err from e results.append(err) + else: + if on_done is not None: + on_done(label(item)) return results def _run_one(self, fn: Callable[[Transport, T], R], item: T) -> R | _Skipped: @@ -204,6 +209,7 @@ def _map_parallel( *, fail_fast: bool, label: Callable[[T], str], + on_done: Callable[[str], None] | None = None, ) -> list[R | TransferError | _Skipped]: workers = min(self.jobs, len(items)) executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="git-ftp") @@ -219,13 +225,17 @@ def _map_parallel( for fut in done: idx = futures.index(fut) try: - results[fut] = fut.result() + result = fut.result() except Exception as e: err = TransferError(label(items[idx]), e) results[fut] = err if fail_fast and first_error is None: first_error = err self._cancel.set() + else: + results[fut] = result + if on_done is not None and not isinstance(result, _Skipped): + on_done(label(items[idx])) except BaseException: self._cancel.set() executor.shutdown(wait=False, cancel_futures=True) @@ -236,22 +246,30 @@ def _map_parallel( return [results[f] for f in futures] # -- typed helpers ----------------------------------------------------- - def upload(self, tasks: Sequence[UploadTask]) -> None: + def upload( + self, tasks: Sequence[UploadTask], *, on_done: Callable[[str], None] | None = None + ) -> None: def do(t: Transport, task: UploadTask) -> None: t.put(task.local, task.remote, task.size) self.out.debug(f"Uploaded '{task.label}'.") - self.map(do, tasks, fail_fast=True, label=lambda task: task.label) + self.map(do, tasks, fail_fast=True, label=lambda task: task.label, on_done=on_done) - def delete(self, tasks: Sequence[DeleteTask]) -> list[TransferError]: + def delete( + self, tasks: Sequence[DeleteTask], *, on_done: Callable[[str], None] | None = None + ) -> list[TransferError]: def do(t: Transport, task: DeleteTask) -> None: t.delete(task.remote) self.out.debug(f"Deleted '{task.label}'.") - results = self.map(do, tasks, fail_fast=False, label=lambda task: task.label) + results = self.map( + do, tasks, fail_fast=False, label=lambda task: task.label, on_done=on_done + ) return [r for r in results if isinstance(r, TransferError)] - def download(self, tasks: Sequence[DownloadTask]) -> None: + def download( + self, tasks: Sequence[DownloadTask], *, on_done: Callable[[str], None] | 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) @@ -266,4 +284,4 @@ def do(t: Transport, task: DownloadTask) -> None: raise self.out.debug(f"Downloaded '{task.label}'.") - self.map(do, tasks, fail_fast=True, label=lambda task: task.label) + self.map(do, tasks, fail_fast=True, label=lambda task: task.label, on_done=on_done) diff --git a/tests/integration/test_entrypoint.py b/tests/integration/test_entrypoint.py index 2dbc597..8217758 100644 --- a/tests/integration/test_entrypoint.py +++ b/tests/integration/test_entrypoint.py @@ -91,3 +91,49 @@ def test_ctrl_c_stops_promptly_and_leaves_log_untouched( assert "Interrupted." in err assert s.remote().log() == first assert not list(repo.path.rglob("*.git-ftp-part")) + + +@pytest.mark.skipif(sys.platform == "win32", reason="pty is POSIX-only") +def test_progress_spinner_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).""" + import pty + import threading + + s = ftp_server + master, slave = pty.openpty() # child stderr is a TTY -> spinner enabled + drained: list[bytes] = [] + + def drain() -> None: + while True: + try: + chunk = os.read(master, 4096) + except OSError: + break + if not chunk: + break + drained.append(chunk) + + reader = threading.Thread(target=drain, daemon=True) + reader.start() + proc = subprocess.run( + [*cli_bin, "init", "-j", "4", *auth(s), s.url()], + cwd=repo.path, + stdout=subprocess.PIPE, + stderr=slave, + text=True, + ) + os.close(slave) + reader.join(5) + stderr = b"".join(drained).decode("utf-8", "replace") + + assert proc.returncode == 0, stderr + # stdout is unpolluted by the spinner: 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). + assert "Uploading" in stderr diff --git a/tests/integration/test_push.py b/tests/integration/test_push.py index 74d8d33..d31e02a 100644 --- a/tests/integration/test_push.py +++ b/tests/integration/test_push.py @@ -258,10 +258,10 @@ def _patch_upload_to_edit_live(monkeypatch: pytest.MonkeyPatch, repo: Repo) -> d seen: dict[str, object] = {} original = tr.TransferPool.upload - def patched(self: tr.TransferPool, tasks: list[tr.UploadTask]) -> None: + def patched(self: tr.TransferPool, tasks: list[tr.UploadTask], **kw: object) -> None: seen["locals"] = [str(t.local) for t in tasks] (repo.path / "test 1.txt").write_text("LIVEedit!\n") - original(self, tasks) + original(self, tasks, **kw) # type: ignore[arg-type] monkeypatch.setattr(tr.TransferPool, "upload", patched) return seen diff --git a/tests/unit/test_progress.py b/tests/unit/test_progress.py new file mode 100644 index 0000000..ed598da --- /dev/null +++ b/tests/unit/test_progress.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import io + +from gitftp.output import Level, Output +from gitftp.progress import Progress + + +class _FakeTTY(io.StringIO): + """A writable stream that claims to be a terminal.""" + + def isatty(self) -> bool: + return True + + +def test_disabled_when_not_a_tty() -> None: + out = Output(Level.NORMAL, stdout=io.StringIO(), stderr=io.StringIO()) + with Progress(out, "Uploading", 3) as p: + assert not p.enabled + p.advance("a.txt") + p.advance("b.txt") + assert p.done == 2 # still counts + assert p._spinner is None + assert out.stderr.getvalue() == "" # nothing drawn + assert out.stdout.getvalue() == "" + + +def test_disabled_at_verbose_and_silent() -> None: + for level in (Level.SILENT, Level.VERBOSE, Level.TRACE): + out = Output(level, stdout=io.StringIO(), stderr=_FakeTTY()) + assert not Progress(out, "Uploading", 1).enabled + + +def test_text_formatting() -> None: + out = Output(Level.NORMAL, stdout=io.StringIO(), stderr=io.StringIO()) + p = Progress(out, "Uploading", 40) + assert p._text() == "Uploading 0/40" + p.done = 3 + assert p._text("dir/app.js") == "Uploading 3/40 dir/app.js" + + +def test_enabled_on_a_tty_draws_only_to_stderr() -> None: + stdout, stderr = io.StringIO(), _FakeTTY() + out = Output(Level.NORMAL, stdout=stdout, stderr=stderr) + p = Progress(out, "Uploading", 2) + assert p.enabled + with p: + assert p._spinner is not None + p.advance("one.txt") + p.advance("two.txt") + assert p._spinner is None # stopped cleanly + assert p.done == 2 + assert stdout.getvalue() == "" # never touches stdout diff --git a/uv.lock b/uv.lock index dbb944d..b937f14 100644 --- a/uv.lock +++ b/uv.lock @@ -478,6 +478,7 @@ dependencies = [ { name = "click" }, { name = "paramiko" }, { name = "pycurl" }, + { name = "yaspin" }, ] [package.dev-dependencies] @@ -499,6 +500,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" }, ] [package.metadata.requires-dev] @@ -996,6 +998,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, ] +[[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" @@ -1079,3 +1090,15 @@ sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3 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" }, ] + +[[package]] +name = "yaspin" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "termcolor" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/67/065003157da680eacd9225ffb92943d0f579a192961ea6c29ebfd258647f/yaspin-3.5.0.tar.gz", hash = "sha256:736d08081bcfc1a18e0c0610939f9cb419441f305ff5d50055d0e4688b36baf9", size = 44051, upload-time = "2026-09-06T18:14:47.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/b6/cc8809db183b011e0cfb49998358b3a3547fa8448bad3f1761a26c8d1e9a/yaspin-3.5.0-py3-none-any.whl", hash = "sha256:e7bce1ee10b5702b03b8cb3ef18793bb0613f0808d0829b26e3f763290f4a57d", size = 22768, upload-time = "2026-09-06T18:14:45.934Z" }, +] From 0c5c8104c783693478c435ce5fb2d6d12fa8b357 Mon Sep 17 00:00:00 2001 From: Rene Moser Date: Sun, 13 Sep 2026 21:49:39 +0200 Subject: [PATCH 2/2] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2010e1d..8762209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ First release: a native Python3 port of git-ftp 1.6.0. See [COMPATIBILITY.md](CO ### Added - `--worktree` / `git-ftp.worktree`: deploy from a temporary Git worktree so edits to the working tree during an upload are ignored. - Parallel uploads, deletes and downloads (`--jobs`, `git-ftp.jobs`). +- Interactive progress spinner (yaspin) with a `done/total` count while files upload, delete or download; shown on stderr on a terminal, silent when piped or under `-n`. - Native `download`, `pull` and `snapshot` without lftp. - `unlock` action, `--password-command`, `--key-passphrase`, `--no-post-hooks`, `GIT_FTP_URL`/`GIT_FTP_USER`/`GIT_FTP_PASSWORD`.