From afd06b41e3631efe6283cfac33e3a6db0123d7f9 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:17:04 -0500 Subject: [PATCH] put -r / get -r: a trailing slash copies the folder's contents in place (#185) --- CHANGELOG.md | 4 ++ cli/src/mpftp/cli.py | 16 +++-- cli/src/mpftp/sidecar.py | 16 +++-- cli/tests/test_put.py | 108 ++++++++++++++++++++++++++++++++++ docs/guides/transfer-files.md | 25 +++++--- docs/reference/cli.md | 2 +- 6 files changed, 151 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f9a744..bc2f059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +- `put -r src/ /dest` (and `get -r /src/ dest`) with a trailing slash copies the folder's contents into the destination, updating files in place, so re-uploading a library no longer nests it. Without the slash nothing changes (#185) + ## v0.1.5 (2026-10-30) - Linux package: mpftp with arguments is the CLI, not the window (#184) (#190) diff --git a/cli/src/mpftp/cli.py b/cli/src/mpftp/cli.py index 5ad4770..809ef4c 100755 --- a/cli/src/mpftp/cli.py +++ b/cli/src/mpftp/cli.py @@ -1124,7 +1124,10 @@ def cmd_put(ns: argparse.Namespace) -> None: client.call( "fs_cp", { - "src": str(local.resolve()), + # resolve() drops a trailing slash; keep it, it means + # "the contents of this folder" (rsync-style). + "src": str(local.resolve()) + + (os.sep if ns.local.endswith(("/", os.sep)) and local.is_dir() else ""), "dest": ":" + dest if not dest.startswith(":") else dest, "verify": verify, "mpy": mpy, @@ -2509,9 +2512,11 @@ def build_parser() -> argparse.ArgumentParser: "-r", "--recursive", action="store_true", - help="Copy a folder. Like cp -r: if REMOTE is already a folder, LOCAL's folder " - "goes inside it (put -r lib /lib, with /lib there, writes /lib/lib); " - "otherwise REMOTE is created and gets LOCAL's contents", + help="Copy a folder. A trailing slash (put -r lib/ /lib) copies LOCAL's contents " + "into REMOTE, creating it if needed and updating files in place; files already " + "on the board that LOCAL lacks are left alone. Without the slash it works like " + "cp -r: if REMOTE is already a folder, LOCAL's folder goes inside it " + "(put -r lib /lib, with /lib there, writes /lib/lib)", ) put.add_argument( "--verify", @@ -2532,7 +2537,8 @@ def build_parser() -> argparse.ArgumentParser: get = sub.add_parser("get", parents=[device_opts], help="Download board file to local") get.add_argument("remote") get.add_argument("local") - get.add_argument("-r", "--recursive", action="store_true", help="Copy directories via fs_cp") + get.add_argument("-r", "--recursive", action="store_true", help="Copy a folder. REMOTE with a trailing slash (get -r /lib/ out) copies its " + "contents into LOCAL; without it, if LOCAL is already a folder, REMOTE's folder goes inside it") get.add_argument( "--verify", action=argparse.BooleanOptionalAction, diff --git a/cli/src/mpftp/sidecar.py b/cli/src/mpftp/sidecar.py index ba4d805..b22d840 100644 --- a/cli/src/mpftp/sidecar.py +++ b/cli/src/mpftp/sidecar.py @@ -4117,6 +4117,8 @@ def _cp_local_to_remote( verified: list[str], mpy: bool = False, ) -> None: + # A trailing slash on a folder means "its contents", like rsync (mpftp#185). + contents_only = src.endswith(("/", "\\")) src_p = Path(src) if not src_p.exists(): # Under WSL the CLI is Linux and this sidecar is Windows Python, @@ -4133,7 +4135,7 @@ def _cp_local_to_remote( "compile-on-upload is MicroPython-only (connected interpreter is circuitpython)" ) self._cp_local_to_circuitpy_msc( - msc_root, src_p, dest, verify, copied, verified + msc_root, src_p, dest, verify, copied, verified, contents_only ) return @@ -4152,7 +4154,7 @@ def stage(name: str, data: bytes) -> tuple[str, bytes]: dest_exists = self._remote_isdir(t, dest) except Exception: dest_exists = False - root = (dest.rstrip("/") + "/" + src_p.name) if dest_exists else dest + root = (dest.rstrip("/") + "/" + src_p.name) if dest_exists and not contents_only else dest try: t.fs_mkdir(root) except Exception: @@ -4222,6 +4224,7 @@ def _cp_local_to_circuitpy_msc( verify: bool, copied: list[str], verified: list[str], + contents_only: bool = False, ) -> None: """Local → board copy via mounted CIRCUITPY (USB MSC).""" @@ -4245,7 +4248,7 @@ def _write_one(local_file: Path, remote_file: str) -> None: if src_p.is_dir(): dest_exists = _host_isdir(dest) - root = (dest.rstrip("/") + "/" + src_p.name) if dest_exists else dest + root = (dest.rstrip("/") + "/" + src_p.name) if dest_exists and not contents_only else dest map_circuitpy_remote_path(msc_root, root).mkdir(parents=True, exist_ok=True) for dirpath, dirnames, filenames in os.walk(src_p): dirnames[:] = [d for d in dirnames if not should_skip_transfer_entry(d)] @@ -4272,8 +4275,10 @@ def _cp_remote_to_local( self, t, src: str, dest: str, verify: bool, copied: list[str], verified: list[str] ) -> None: dest_p = Path(dest) + contents_only = src.endswith("/") and len(src) > 1 if self._remote_isdir(t, src): - root = dest_p / Path(src).name if dest_p.exists() and dest_p.is_dir() else dest_p + nest = dest_p.exists() and dest_p.is_dir() and not contents_only + root = dest_p / Path(src).name if nest else dest_p root.mkdir(parents=True, exist_ok=True) def walk(remote: str, local: Path) -> None: @@ -4316,7 +4321,8 @@ def _cp_remote_to_remote( self, t, src: str, dest: str, verify: bool, copied: list[str], verified: list[str] ) -> None: if self._remote_isdir(t, src): - root = dest.rstrip("/") + "/" + Path(src).name if self._remote_isdir(t, dest) else dest + nest = self._remote_isdir(t, dest) and not (src.endswith("/") and len(src) > 1) + root = dest.rstrip("/") + "/" + Path(src).name if nest else dest try: t.fs_mkdir(root) except Exception: diff --git a/cli/tests/test_put.py b/cli/tests/test_put.py index 66ca097..c2a5adc 100644 --- a/cli/tests/test_put.py +++ b/cli/tests/test_put.py @@ -124,3 +124,111 @@ def test_names_the_missing_folder(self): s._cp_local_to_remote(t, d, "/repro/cast", True, [], []) self.assertIn("/repro isn't on the board", str(cm.exception)) t.fs_writefile.assert_not_called() + + +class _FakeBoard: + """A dict-backed transport: just enough of the fs_* calls for folder copies.""" + + def __init__(self, dirs=(), files=None): + self.dirs = {"/", *dirs} + self.files = dict(files or {}) + + def fs_stat(self, path): + path = path.rstrip("/") or "/" + if path in self.dirs: + return mock.Mock(st_mode=0x4000) + if path in self.files: + return mock.Mock(st_mode=0x8000) + raise OSError(2, "ENOENT") + + def fs_mkdir(self, path): + path = path.rstrip("/") + if path in self.dirs or path in self.files: + raise OSError(17, "EEXIST") + if (path.rsplit("/", 1)[0] or "/") not in self.dirs: + raise OSError(2, "ENOENT") + self.dirs.add(path) + + def fs_writefile(self, path, data): + self.files[path] = data + + def fs_hashfile(self, path, algo): + import hashlib + + return hashlib.sha256(self.files[path]).digest() + + +def _session(): + from mpftp import sidecar + + s = sidecar.Session.__new__(sidecar.Session) + s._circuitpy_msc_root = lambda: None + s._sync_remote_fs = lambda t: None + return s + + +class TrailingSlashTests(unittest.TestCase): + """mpftp#185: `put -r src/ /dest` copies src's contents into /dest, rsync-style.""" + + def _tree(self, d, files): + for name, text in files.items(): + p = Path(d) / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(text) + + def test_trailing_slash_updates_existing_folder_in_place(self): + board = _FakeBoard(dirs=["/lib", "/lib/sub"], files={"/lib/a.py": b"old", "/lib/stale.py": b"x"}) + with tempfile.TemporaryDirectory() as d: + src = Path(d) / "lib" + self._tree(src, {"a.py": "new", "sub/b.py": "b"}) + copied = [] + _session()._cp_local_to_remote(board, str(src) + "/", "/lib", True, copied, []) + self.assertEqual(board.files["/lib/a.py"], b"new") + self.assertEqual(board.files["/lib/sub/b.py"], b"b") + self.assertNotIn("/lib/lib", board.dirs) + self.assertIn("/lib/stale.py", board.files) # like rsync without --delete + + def test_no_trailing_slash_still_nests_into_existing_folder(self): + board = _FakeBoard(dirs=["/lib"]) + with tempfile.TemporaryDirectory() as d: + src = Path(d) / "cast" + self._tree(src, {"a.py": "new"}) + _session()._cp_local_to_remote(board, str(src), "/lib", True, [], []) + self.assertIn("/lib/cast/a.py", board.files) + + def test_trailing_slash_creates_missing_destination(self): + board = _FakeBoard() + with tempfile.TemporaryDirectory() as d: + src = Path(d) / "cast" + self._tree(src, {"a.py": "new"}) + _session()._cp_local_to_remote(board, str(src) + "/", "/cast", True, [], []) + self.assertIn("/cast/a.py", board.files) + + def test_cli_keeps_the_trailing_slash(self): + client = mock.Mock() + client.call.return_value = {"ok": True} + with tempfile.TemporaryDirectory() as td: + src = Path(td) / "dir" + src.mkdir() + for arg, want in ((str(src) + "/", True), (str(src), False)): + client.reset_mock() + with mock.patch("mpftp.cli.get_client", return_value=(client, "tcp")), mock.patch( + "mpftp.cli.ensure_device" + ), redirect_stdout(io.StringIO()): + cmd_put(argparse.Namespace( + local=arg, remote="/d", recursive=True, mpy=False, verify=True, + device="COM1", baud=115200)) + got = client.call.call_args[0][1]["src"] + self.assertEqual(got.endswith("/"), want) + + def test_get_trailing_slash_copies_contents_into_existing_folder(self): + s = _session() + s._board_listdir = lambda t, p: [__import__("types").SimpleNamespace(st_mode=0x8000, name="a.py")] + t = _FakeBoard(dirs=["/lib"], files={"/lib/a.py": b"A"}) + t.fs_readfile = lambda p: t.files[p] + with tempfile.TemporaryDirectory() as d: + (Path(d) / "out").mkdir() + s._cp_remote_to_local(t, "/lib/", str(Path(d) / "out"), True, [], []) + self.assertEqual((Path(d) / "out" / "a.py").read_bytes(), b"A") + s._cp_remote_to_local(t, "/lib", str(Path(d) / "out"), True, [], []) + self.assertEqual((Path(d) / "out" / "lib" / "a.py").read_bytes(), b"A") diff --git a/docs/guides/transfer-files.md b/docs/guides/transfer-files.md index 0efa79c..1d8571d 100644 --- a/docs/guides/transfer-files.md +++ b/docs/guides/transfer-files.md @@ -33,19 +33,26 @@ mpftp df `put`, `get` and `cp` verify with SHA-256 unless you pass `--no-verify`. Add `-r` to `put` and `get` to copy a folder. -A folder copy works like `cp -r`. When the destination folder doesn't exist -yet, it's created and gets the folder's contents. When it does exist, the -folder goes inside it. So to update a library already on the board, put it -into the folder above: +A trailing slash on the folder you copy means "its contents", the way rsync +reads it. To update a library already on the board, put it with the slash: ```bash -mpftp put -r ./cast /cast # first time: creates /cast -mpftp put -r ./cast / # again: replaces the files in /cast +mpftp put -r ./cast/ /cast # copies cast's files into /cast, created if missing +mpftp put -r ./cast/ /cast # again: changed files are replaced in place ``` -Putting `./cast` to `/cast` a second time writes `/cast/cast/...` and leaves -the old files where your code imports them from. The folder above the -destination has to exist: make it first with `mpftp mkdir`. +Files on the board that aren't in `./cast` are left alone, as with rsync +without `--delete`. Remove them with `mpftp rm`. + +Without the slash, a folder copy works like `cp -r`. When the destination +folder doesn't exist yet, it's created and gets the folder's contents. When it +does exist, the folder goes inside it, so `mpftp put -r ./cast /cast` a second +time writes `/cast/cast/...` and leaves the old files where your code imports +them from. Add the slash, or put `./cast` into the folder above (`/`). The +folder above the destination has to exist: make it first with `mpftp mkdir`. + +`get -r` reads the slash the same way: `mpftp get -r /cast/ ./cast` copies the +contents of `/cast` into `./cast` even when `./cast` already exists. ## Compile on upload diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 7d6af36..012a351 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -23,7 +23,7 @@ and exits non-zero. | Command | What it does | |---|---| | `ls`, `tree`, `df` | List, tree and free space | -| `put LOCAL REMOTE` | Upload. `-r` for a folder (into REMOTE if that folder exists, [like `cp -r`](../guides/transfer-files.md)), `--no-verify` to skip SHA-256, `--mpy` to compile | +| `put LOCAL REMOTE` | Upload. `-r` for a folder; `put -r lib/ /lib` (trailing slash) copies the folder's contents into `/lib`, and without the slash an existing REMOTE gets the folder inside it, [like `cp -r`](../guides/transfer-files.md), `--no-verify` to skip SHA-256, `--mpy` to compile | | `get REMOTE LOCAL` | Download, same options | | `cp SRC DST` | Copy; `:` before a path marks the board side | | `hash`, `mkdir`, `rm`, `touch`, `rename` | As named. `rm -r` removes a tree |