Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
3 changes: 3 additions & 0 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ dependencies = [
"click>=8.2,<9",
"pycurl>=7.46,<8",
"paramiko>=3.4,<6",
"yaspin>=3.5,<4",
]

[project.urls]
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions src/gitftp/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion src/gitftp/mirror.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).")
Expand Down
55 changes: 55 additions & 0 deletions src/gitftp/progress.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""An interactive transfer spinner (yaspin), rendered on stderr.

The spinner shows a running ``<verb> <done>/<total> <current file>`` 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
36 changes: 27 additions & 9 deletions src/gitftp/transfer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Comment on lines +155 to 156
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,
Expand All @@ -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()
Expand All @@ -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:
Expand All @@ -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")
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
46 changes: 46 additions & 0 deletions tests/integration/test_entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions tests/integration/test_push.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions tests/unit/test_progress.py
Original file line number Diff line number Diff line change
@@ -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
Loading