From bfd08c3a16e9a095bd9030660fe2575c3b489eab Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:01:11 -0500 Subject: [PATCH 1/3] put -r: say where the folder goes, and name a missing parent folder (#185) --- cli/src/mpftp/cli.py | 9 ++++++++- cli/src/mpftp/sidecar.py | 10 ++++++++++ cli/tests/test_put.py | 22 ++++++++++++++++++++++ docs/guides/transfer-files.md | 14 ++++++++++++++ docs/reference/cli.md | 2 +- 5 files changed, 55 insertions(+), 2 deletions(-) diff --git a/cli/src/mpftp/cli.py b/cli/src/mpftp/cli.py index 6b42465..db3c486 100755 --- a/cli/src/mpftp/cli.py +++ b/cli/src/mpftp/cli.py @@ -2450,7 +2450,14 @@ def build_parser() -> argparse.ArgumentParser: put = sub.add_parser("put", parents=[device_opts], help="Upload local file to board") put.add_argument("local") put.add_argument("remote") - put.add_argument("-r", "--recursive", action="store_true", help="Copy directories via fs_cp") + put.add_argument( + "-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", + ) put.add_argument( "--verify", action=argparse.BooleanOptionalAction, diff --git a/cli/src/mpftp/sidecar.py b/cli/src/mpftp/sidecar.py index 2a874b2..c159cf3 100644 --- a/cli/src/mpftp/sidecar.py +++ b/cli/src/mpftp/sidecar.py @@ -4143,6 +4143,16 @@ def stage(name: str, data: bytes) -> tuple[str, bytes]: t.fs_mkdir(root) except Exception: pass + try: + made = self._remote_isdir(t, root) + except Exception: + made = False + if not made: + parent = root.rstrip("/").rsplit("/", 1)[0] or "/" + raise RuntimeError( + f"can't create {root}: the folder {parent} isn't on the board " + f"(make it first: mpftp mkdir {parent})" + ) for dirpath, dirnames, filenames in os.walk(src): # Prune ignored dirs in-place so os.walk does not descend into them. dirnames[:] = [d for d in dirnames if not should_skip_transfer_entry(d)] diff --git a/cli/tests/test_put.py b/cli/tests/test_put.py index 5738c68..66ca097 100644 --- a/cli/tests/test_put.py +++ b/cli/tests/test_put.py @@ -102,3 +102,25 @@ def test_nothing_changes_off_windows(self): with mock.patch.object(sidecar.os, "name", "posix"): self.assertIsNone(sidecar._windows_spelling("/mnt/c/Users/me/lib")) + + +class PutIntoMissingParentTests(unittest.TestCase): + """mpftp#185: a missing parent folder is named, not a file inside it.""" + + def test_names_the_missing_folder(self): + import tempfile + from unittest import mock as _mock + + from mpftp import sidecar + + t = _mock.Mock() + t.fs_mkdir.side_effect = OSError(2, "ENOENT") + t.fs_stat.side_effect = OSError(2, "ENOENT") + s = sidecar.Session.__new__(sidecar.Session) + s._circuitpy_msc_root = lambda: None + with tempfile.TemporaryDirectory() as d: + (Path(d) / "a.py").write_text("v = 1\n") + with self.assertRaises(RuntimeError) as cm: + 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() diff --git a/docs/guides/transfer-files.md b/docs/guides/transfer-files.md index 3fd8c81..0efa79c 100644 --- a/docs/guides/transfer-files.md +++ b/docs/guides/transfer-files.md @@ -33,6 +33,20 @@ 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: + +```bash +mpftp put -r ./cast /cast # first time: creates /cast +mpftp put -r ./cast / # again: replaces the files in /cast +``` + +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`. + ## Compile on upload `--mpy` on `put` or `cp` (and the `mpftp.compileOnUpload` setting for the diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 11f18f9..7d6af36 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, `--no-verify` to skip SHA-256, `--mpy` to compile | +| `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 | | `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 | From 7e73bd2903af9ddd2485bcf5021118270e965f6d Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:07:41 -0500 Subject: [PATCH 2/3] Check the destination only when mkdir fails --- cli/src/mpftp/sidecar.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/cli/src/mpftp/sidecar.py b/cli/src/mpftp/sidecar.py index c159cf3..c6344fe 100644 --- a/cli/src/mpftp/sidecar.py +++ b/cli/src/mpftp/sidecar.py @@ -4142,17 +4142,14 @@ def stage(name: str, data: bytes) -> tuple[str, bytes]: try: t.fs_mkdir(root) except Exception: - pass - try: - made = self._remote_isdir(t, root) - except Exception: - made = False - if not made: - parent = root.rstrip("/").rsplit("/", 1)[0] or "/" - raise RuntimeError( - f"can't create {root}: the folder {parent} isn't on the board " - f"(make it first: mpftp mkdir {parent})" - ) + # Already there is fine; a missing parent isn't, and would + # otherwise surface as a file inside it not being found. + if not self._remote_isdir(t, root): + parent = root.rstrip("/").rsplit("/", 1)[0] or "/" + raise RuntimeError( + f"can't create {root}: the folder {parent} isn't on the board " + f"(make it first: mpftp mkdir {parent})" + ) for dirpath, dirnames, filenames in os.walk(src): # Prune ignored dirs in-place so os.walk does not descend into them. dirnames[:] = [d for d in dirnames if not should_skip_transfer_entry(d)] From cb14dfcedb042430540a4cc624df3a30df80a39c Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:13:38 -0500 Subject: [PATCH 3/3] Raise the missing-folder error from None --- cli/src/mpftp/sidecar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/mpftp/sidecar.py b/cli/src/mpftp/sidecar.py index c6344fe..be15d79 100644 --- a/cli/src/mpftp/sidecar.py +++ b/cli/src/mpftp/sidecar.py @@ -4149,7 +4149,7 @@ def stage(name: str, data: bytes) -> tuple[str, bytes]: raise RuntimeError( f"can't create {root}: the folder {parent} isn't on the board " f"(make it first: mpftp mkdir {parent})" - ) + ) from None for dirpath, dirnames, filenames in os.walk(src): # Prune ignored dirs in-place so os.walk does not descend into them. dirnames[:] = [d for d in dirnames if not should_skip_transfer_entry(d)]