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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
16 changes: 11 additions & 5 deletions cli/src/mpftp/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand Down
16 changes: 11 additions & 5 deletions cli/src/mpftp/sidecar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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)."""

Expand All @@ -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)]
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
108 changes: 108 additions & 0 deletions cli/tests/test_put.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
25 changes: 16 additions & 9 deletions docs/guides/transfer-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading