mac-android-file-transfer 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ .env
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ __pycache__/
6
+ *.py[cod]
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: mac-android-file-transfer
3
+ Version: 0.1.0
4
+ Summary: macOS CLI wrapper around go-mtpfs for Android file transfer
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+
8
+ # mac-android-file-transfer
9
+
10
+ `maft` is a macOS CLI wrapper around
11
+ [`github.com/ganeshrvel/go-mtpfs`](https://github.com/ganeshrvel/go-mtpfs).
12
+ It mounts Android MTP storage to a local folder, then provides explicit `cp`,
13
+ `mv`, and `rm` commands over that mounted filesystem.
14
+
15
+ ## Requirements
16
+
17
+ - macOS
18
+ - macFUSE
19
+ - `go-mtpfs` available in `PATH`
20
+ - `diskutil` or `/sbin/umount` for unmounting
21
+
22
+ Run:
23
+
24
+ ```sh
25
+ maft doctor
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```sh
31
+ maft mount ~/Android
32
+ maft cp --mount ~/Android ~/Downloads/photo.jpg DCIM/photo.jpg
33
+ maft cp --mount ~/Android -r DCIM ~/Desktop/DCIM
34
+ maft mv --mount ~/Android DCIM/photo.jpg Pictures/photo.jpg
35
+ maft rm --mount ~/Android Pictures/photo.jpg
36
+ maft rm --mount ~/Android -r Pictures/old-folder
37
+ maft unmount ~/Android
38
+ ```
39
+
40
+ For file commands, absolute paths and `~/...` paths are treated as host paths.
41
+ Other paths are treated as Android paths relative to the mount folder.
@@ -0,0 +1,34 @@
1
+ # mac-android-file-transfer
2
+
3
+ `maft` is a macOS CLI wrapper around
4
+ [`github.com/ganeshrvel/go-mtpfs`](https://github.com/ganeshrvel/go-mtpfs).
5
+ It mounts Android MTP storage to a local folder, then provides explicit `cp`,
6
+ `mv`, and `rm` commands over that mounted filesystem.
7
+
8
+ ## Requirements
9
+
10
+ - macOS
11
+ - macFUSE
12
+ - `go-mtpfs` available in `PATH`
13
+ - `diskutil` or `/sbin/umount` for unmounting
14
+
15
+ Run:
16
+
17
+ ```sh
18
+ maft doctor
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ```sh
24
+ maft mount ~/Android
25
+ maft cp --mount ~/Android ~/Downloads/photo.jpg DCIM/photo.jpg
26
+ maft cp --mount ~/Android -r DCIM ~/Desktop/DCIM
27
+ maft mv --mount ~/Android DCIM/photo.jpg Pictures/photo.jpg
28
+ maft rm --mount ~/Android Pictures/photo.jpg
29
+ maft rm --mount ~/Android -r Pictures/old-folder
30
+ maft unmount ~/Android
31
+ ```
32
+
33
+ For file commands, absolute paths and `~/...` paths are treated as host paths.
34
+ Other paths are treated as Android paths relative to the mount folder.
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "mac-android-file-transfer"
3
+ version = "0.1.0"
4
+ description = "macOS CLI wrapper around go-mtpfs for Android file transfer"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = []
8
+
9
+ [project.scripts]
10
+ maft = "maft.cli:main"
11
+
12
+ [build-system]
13
+ requires = ["hatchling"]
14
+ build-backend = "hatchling.build"
15
+
16
+ [tool.hatch.build.targets.wheel]
17
+ packages = ["src/maft"]
18
+
19
+ [tool.ruff]
20
+ line-length = 100
21
+ target-version = "py312"
22
+
23
+ [tool.ruff.lint]
24
+ select = ["E", "F", "I", "B", "UP", "SIM", "RUF"]
25
+
26
+ [tool.basedpyright]
27
+ include = ["src", "tests"]
28
+ pythonVersion = "3.12"
29
+ typeCheckingMode = "strict"
@@ -0,0 +1,5 @@
1
+ """macOS Android file transfer CLI."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,385 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from dataclasses import asdict, dataclass
11
+ from pathlib import Path
12
+ from typing import cast
13
+
14
+ from maft import __version__
15
+
16
+ APP_NAME = "maft"
17
+ DEFAULT_STATE_DIR = Path.home() / "Library" / "Application Support" / APP_NAME
18
+ DEFAULT_MACFUSE_PATHS = (
19
+ Path("/Library/Filesystems/macfuse.fs"),
20
+ Path("/Library/Filesystems/osxfuse.fs"),
21
+ )
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class MountRecord:
26
+ mountpoint: str
27
+ pid: int
28
+ command: list[str]
29
+ created_at: float
30
+
31
+
32
+ class CliError(Exception):
33
+ pass
34
+
35
+
36
+ def main(argv: list[str] | None = None) -> int:
37
+ parser = build_parser()
38
+ args = parser.parse_args(argv)
39
+ try:
40
+ return args.func(args)
41
+ except CliError as exc:
42
+ print(f"maft: {exc}", file=sys.stderr)
43
+ return 1
44
+
45
+
46
+ def build_parser() -> argparse.ArgumentParser:
47
+ parser = argparse.ArgumentParser(
48
+ prog="maft",
49
+ description="Mount and manage Android MTP files on macOS via go-mtpfs.",
50
+ )
51
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
52
+ subparsers = parser.add_subparsers(dest="command", required=True)
53
+
54
+ doctor = subparsers.add_parser("doctor", help="check local dependencies")
55
+ doctor.set_defaults(func=cmd_doctor)
56
+
57
+ mount = subparsers.add_parser("mount", help="mount an Android device")
58
+ mount.add_argument("mountpoint")
59
+ mount.add_argument("--dev", help="go-mtpfs device selector")
60
+ mount.add_argument("--storage", help="go-mtpfs storage selector")
61
+ mount.add_argument("--android", dest="android", action="store_true", default=True)
62
+ mount.add_argument("--no-android", dest="android", action="store_false")
63
+ mount.add_argument("--allow-other", action="store_true")
64
+ mount.add_argument("--debug", action="store_true")
65
+ mount.add_argument("--usb-timeout", type=int, help="go-mtpfs USB timeout")
66
+ mount.set_defaults(func=cmd_mount)
67
+
68
+ unmount = subparsers.add_parser("unmount", help="unmount an Android mount folder")
69
+ unmount.add_argument("mountpoint")
70
+ unmount.set_defaults(func=cmd_unmount)
71
+
72
+ cp = subparsers.add_parser("cp", help="copy files to, from, or within a mounted device")
73
+ add_mount_arg(cp)
74
+ cp.add_argument("-r", "--recursive", action="store_true", help="copy directories recursively")
75
+ cp.add_argument("src")
76
+ cp.add_argument("dst")
77
+ cp.set_defaults(func=cmd_cp)
78
+
79
+ mv = subparsers.add_parser("mv", help="move files to, from, or within a mounted device")
80
+ add_mount_arg(mv)
81
+ mv.add_argument("src")
82
+ mv.add_argument("dst")
83
+ mv.set_defaults(func=cmd_mv)
84
+
85
+ rm = subparsers.add_parser("rm", help="remove files from a mounted device")
86
+ add_mount_arg(rm)
87
+ rm.add_argument("-r", "--recursive", action="store_true", help="remove directories recursively")
88
+ rm.add_argument("paths", nargs="+")
89
+ rm.set_defaults(func=cmd_rm)
90
+
91
+ return parser
92
+
93
+
94
+ def add_mount_arg(parser: argparse.ArgumentParser) -> None:
95
+ parser.add_argument(
96
+ "--mount",
97
+ required=True,
98
+ dest="mountpoint",
99
+ help="mounted Android folder",
100
+ )
101
+
102
+
103
+ def cmd_doctor(_args: argparse.Namespace) -> int:
104
+ checks = [
105
+ ("go-mtpfs", shutil.which("go-mtpfs") is not None, install_go_mtpfs_help()),
106
+ ("diskutil", shutil.which("diskutil") is not None, "Provided by macOS."),
107
+ ("/sbin/umount", Path("/sbin/umount").exists(), "Provided by macOS."),
108
+ ("macFUSE", macfuse_available(), "Install from https://macfuse.github.io/."),
109
+ ]
110
+
111
+ failed = False
112
+ for name, ok, help_text in checks:
113
+ status = "ok" if ok else "missing"
114
+ print(f"{name}: {status}")
115
+ if not ok:
116
+ failed = True
117
+ print(f" {help_text}")
118
+ return 1 if failed else 0
119
+
120
+
121
+ def cmd_mount(args: argparse.Namespace) -> int:
122
+ go_mtpfs = shutil.which("go-mtpfs")
123
+ if go_mtpfs is None:
124
+ raise CliError(f"go-mtpfs not found in PATH. {install_go_mtpfs_help()}")
125
+ if not macfuse_available():
126
+ raise CliError("macFUSE was not detected. Install it from https://macfuse.github.io/.")
127
+
128
+ mountpoint = expand_path(args.mountpoint)
129
+ if mountpoint.exists() and not mountpoint.is_dir():
130
+ raise CliError(f"mountpoint exists and is not a directory: {mountpoint}")
131
+ mountpoint.mkdir(parents=True, exist_ok=True)
132
+
133
+ command = [go_mtpfs]
134
+ if args.dev:
135
+ command.extend(["-dev", args.dev])
136
+ if args.storage:
137
+ command.extend(["-storage", args.storage])
138
+ if not args.android:
139
+ command.append("-android=false")
140
+ if args.allow_other:
141
+ command.append("-allow-other")
142
+ if args.debug:
143
+ command.append("-debug")
144
+ if args.usb_timeout is not None:
145
+ command.extend(["-usb-timeout", str(args.usb_timeout)])
146
+ command.append(str(mountpoint))
147
+
148
+ stdout = None if args.debug else subprocess.DEVNULL
149
+ stderr = None if args.debug else subprocess.DEVNULL
150
+ process = subprocess.Popen(command, stdout=stdout, stderr=stderr, start_new_session=True)
151
+ write_mount_record(
152
+ MountRecord(
153
+ mountpoint=str(mountpoint),
154
+ pid=process.pid,
155
+ command=command,
156
+ created_at=time.time(),
157
+ )
158
+ )
159
+ print(f"mounted {mountpoint} with go-mtpfs pid {process.pid}")
160
+ return 0
161
+
162
+
163
+ def cmd_unmount(args: argparse.Namespace) -> int:
164
+ mountpoint = require_dir(expand_path(args.mountpoint), "mountpoint")
165
+ errors: list[str] = []
166
+ diskutil = shutil.which("diskutil")
167
+ if diskutil is not None:
168
+ result = subprocess.run(
169
+ [diskutil, "unmount", str(mountpoint)],
170
+ check=False,
171
+ capture_output=True,
172
+ text=True,
173
+ )
174
+ if result.returncode == 0:
175
+ remove_mount_record(mountpoint)
176
+ print(f"unmounted {mountpoint}")
177
+ return 0
178
+ errors.append(result.stderr.strip() or result.stdout.strip() or "diskutil unmount failed")
179
+
180
+ umount = shutil.which("umount") or "/sbin/umount"
181
+ result = subprocess.run(
182
+ [umount, str(mountpoint)],
183
+ check=False,
184
+ capture_output=True,
185
+ text=True,
186
+ )
187
+ if result.returncode == 0:
188
+ remove_mount_record(mountpoint)
189
+ print(f"unmounted {mountpoint}")
190
+ return 0
191
+ errors.append(result.stderr.strip() or result.stdout.strip() or "umount failed")
192
+
193
+ joined = "; ".join(error for error in errors if error)
194
+ raise CliError(f"could not unmount {mountpoint}: {joined}")
195
+
196
+
197
+ def cmd_cp(args: argparse.Namespace) -> int:
198
+ mountpoint = require_mountpoint(args.mountpoint)
199
+ src = resolve_operation_path(mountpoint, args.src)
200
+ dst = resolve_operation_path(mountpoint, args.dst)
201
+
202
+ if src.path.is_dir():
203
+ if not args.recursive:
204
+ raise CliError(f"{src.original} is a directory; pass --recursive")
205
+ copy_directory(src.path, destination_for_copy(dst.path, src.path.name))
206
+ else:
207
+ copy_file(src.path, destination_for_copy(dst.path, src.path.name))
208
+ return 0
209
+
210
+
211
+ def cmd_mv(args: argparse.Namespace) -> int:
212
+ mountpoint = require_mountpoint(args.mountpoint)
213
+ src = resolve_operation_path(mountpoint, args.src)
214
+ dst = resolve_operation_path(mountpoint, args.dst)
215
+ target = destination_for_copy(dst.path, src.path.name)
216
+ target.parent.mkdir(parents=True, exist_ok=True)
217
+ shutil.move(str(src.path), str(target))
218
+ return 0
219
+
220
+
221
+ def cmd_rm(args: argparse.Namespace) -> int:
222
+ mountpoint = require_mountpoint(args.mountpoint)
223
+ for raw_path in args.paths:
224
+ if is_host_path(raw_path):
225
+ raise CliError(f"rm only accepts Android-relative paths, got host path: {raw_path}")
226
+ target = resolve_android_path(mountpoint, raw_path)
227
+ if target.is_dir():
228
+ if not args.recursive:
229
+ raise CliError(f"{raw_path} is a directory; pass --recursive")
230
+ shutil.rmtree(target)
231
+ elif target.exists():
232
+ target.unlink()
233
+ else:
234
+ raise CliError(f"path does not exist: {raw_path}")
235
+ return 0
236
+
237
+
238
+ @dataclass(frozen=True)
239
+ class OperationPath:
240
+ original: str
241
+ path: Path
242
+ is_host: bool
243
+
244
+
245
+ def resolve_operation_path(mountpoint: Path, value: str) -> OperationPath:
246
+ if is_host_path(value):
247
+ return OperationPath(value, expand_path(value), True)
248
+ return OperationPath(value, resolve_android_path(mountpoint, value), False)
249
+
250
+
251
+ def resolve_android_path(mountpoint: Path, value: str) -> Path:
252
+ if value in {"", "."}:
253
+ return mountpoint
254
+ candidate = (mountpoint / value).resolve(strict=False)
255
+ try:
256
+ candidate.relative_to(mountpoint)
257
+ except ValueError as exc:
258
+ raise CliError(f"Android path escapes mountpoint: {value}") from exc
259
+ return candidate
260
+
261
+
262
+ def is_host_path(value: str) -> bool:
263
+ return value.startswith("/") or value.startswith("~/") or value == "~"
264
+
265
+
266
+ def destination_for_copy(destination: Path, source_name: str) -> Path:
267
+ if destination.exists() and destination.is_dir():
268
+ return destination / source_name
269
+ if str(destination).endswith(os.sep):
270
+ destination.mkdir(parents=True, exist_ok=True)
271
+ return destination / source_name
272
+ return destination
273
+
274
+
275
+ def copy_file(src: Path, dst: Path) -> None:
276
+ if not src.is_file():
277
+ raise CliError(f"source file does not exist: {src}")
278
+ dst.parent.mkdir(parents=True, exist_ok=True)
279
+ shutil.copy2(src, dst)
280
+
281
+
282
+ def copy_directory(src: Path, dst: Path) -> None:
283
+ if not src.is_dir():
284
+ raise CliError(f"source directory does not exist: {src}")
285
+ dst.parent.mkdir(parents=True, exist_ok=True)
286
+ shutil.copytree(src, dst, dirs_exist_ok=True)
287
+
288
+
289
+ def require_mountpoint(value: str) -> Path:
290
+ mountpoint = require_dir(expand_path(value), "mountpoint")
291
+ return mountpoint.resolve(strict=True)
292
+
293
+
294
+ def require_dir(path: Path, label: str) -> Path:
295
+ if not path.exists():
296
+ raise CliError(f"{label} does not exist: {path}")
297
+ if not path.is_dir():
298
+ raise CliError(f"{label} is not a directory: {path}")
299
+ return path
300
+
301
+
302
+ def expand_path(value: str) -> Path:
303
+ return Path(value).expanduser().resolve(strict=False)
304
+
305
+
306
+ def state_dir() -> Path:
307
+ override = os.environ.get("MAFT_STATE_DIR")
308
+ return Path(override).expanduser() if override else DEFAULT_STATE_DIR
309
+
310
+
311
+ def state_file() -> Path:
312
+ return state_dir() / "mounts.json"
313
+
314
+
315
+ def load_mount_records() -> dict[str, MountRecord]:
316
+ path = state_file()
317
+ if not path.exists():
318
+ return {}
319
+ data: object = json.loads(path.read_text(encoding="utf-8"))
320
+ if not isinstance(data, dict):
321
+ raise CliError(f"invalid state file: {path}")
322
+ raw_records = cast("dict[object, object]", data)
323
+ records: dict[str, MountRecord] = {}
324
+ for mountpoint, record in raw_records.items():
325
+ if not isinstance(mountpoint, str) or not isinstance(record, dict):
326
+ continue
327
+ raw_record = cast("dict[object, object]", record)
328
+ raw_command = raw_record.get("command")
329
+ raw_mountpoint = raw_record.get("mountpoint")
330
+ raw_pid = raw_record.get("pid")
331
+ raw_created_at = raw_record.get("created_at")
332
+ if (
333
+ not isinstance(raw_mountpoint, str)
334
+ or not isinstance(raw_pid, int)
335
+ or not isinstance(raw_created_at, int | float)
336
+ or not isinstance(raw_command, list)
337
+ ):
338
+ continue
339
+ command = cast("list[object]", raw_command)
340
+ records[mountpoint] = MountRecord(
341
+ mountpoint=raw_mountpoint,
342
+ pid=raw_pid,
343
+ command=[str(part) for part in command],
344
+ created_at=float(raw_created_at),
345
+ )
346
+ return records
347
+
348
+
349
+ def save_mount_records(records: dict[str, MountRecord]) -> None:
350
+ directory = state_dir()
351
+ directory.mkdir(parents=True, exist_ok=True)
352
+ state_file().write_text(
353
+ json.dumps({key: asdict(value) for key, value in records.items()}, indent=2) + "\n",
354
+ encoding="utf-8",
355
+ )
356
+
357
+
358
+ def write_mount_record(record: MountRecord) -> None:
359
+ records = load_mount_records()
360
+ records[record.mountpoint] = record
361
+ save_mount_records(records)
362
+
363
+
364
+ def remove_mount_record(mountpoint: Path) -> None:
365
+ records = load_mount_records()
366
+ records.pop(str(mountpoint.resolve(strict=False)), None)
367
+ save_mount_records(records)
368
+
369
+
370
+ def macfuse_available() -> bool:
371
+ override = os.environ.get("MAFT_MACFUSE_PATHS")
372
+ paths = [Path(path).expanduser() for path in override.split(os.pathsep)] if override else list(
373
+ DEFAULT_MACFUSE_PATHS
374
+ )
375
+ return any(path.exists() for path in paths)
376
+
377
+
378
+ def install_go_mtpfs_help() -> str:
379
+ return (
380
+ "Install Go and go-mtpfs, for example: "
381
+ "brew install go libusb pkg-config && go install github.com/ganeshrvel/go-mtpfs@latest"
382
+ )
383
+
384
+ if __name__ == "__main__":
385
+ raise SystemExit(main())
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import stat
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+
11
+ ROOT = Path(__file__).resolve().parents[2]
12
+ PYTHONPATH = str(ROOT / "src")
13
+
14
+
15
+ def run_maft(
16
+ tmp_path: Path,
17
+ *args: str,
18
+ path: str | None = None,
19
+ ) -> subprocess.CompletedProcess[str]:
20
+ env = os.environ.copy()
21
+ env["PYTHONPATH"] = PYTHONPATH
22
+ env["MAFT_STATE_DIR"] = str(tmp_path / "state")
23
+ env["MAFT_MACFUSE_PATHS"] = str(tmp_path / "macfuse.fs")
24
+ if path is not None:
25
+ env["PATH"] = path
26
+ return subprocess.run(
27
+ [sys.executable, "-m", "maft.cli", *args],
28
+ cwd=ROOT,
29
+ env=env,
30
+ check=False,
31
+ capture_output=True,
32
+ text=True,
33
+ )
34
+
35
+
36
+ def make_executable(path: Path, content: str) -> None:
37
+ path.write_text(content, encoding="utf-8")
38
+ path.chmod(path.stat().st_mode | stat.S_IXUSR)
39
+
40
+
41
+ def test_version_flag_prints_current_version(tmp_path: Path) -> None:
42
+ result = run_maft(tmp_path, "--version")
43
+
44
+ assert result.returncode == 0
45
+ assert result.stdout == "maft 0.1.0\n"
46
+
47
+
48
+ def test_doctor_reports_missing_backend(tmp_path: Path) -> None:
49
+ result = run_maft(tmp_path, "doctor", path="/usr/bin:/bin")
50
+
51
+ assert result.returncode == 1
52
+ assert "go-mtpfs: missing" in result.stdout
53
+ assert "macFUSE: missing" in result.stdout
54
+
55
+
56
+ def test_mount_invokes_go_mtpfs_and_records_metadata(tmp_path: Path) -> None:
57
+ bin_dir = tmp_path / "bin"
58
+ bin_dir.mkdir()
59
+ log = tmp_path / "go-mtpfs.log"
60
+ make_executable(
61
+ bin_dir / "go-mtpfs",
62
+ f"#!/bin/sh\nprintf '%s\\n' \"$@\" > {log}\nexit 0\n",
63
+ )
64
+ (tmp_path / "macfuse.fs").mkdir()
65
+ mountpoint = tmp_path / "Android"
66
+
67
+ result = run_maft(
68
+ tmp_path,
69
+ "mount",
70
+ str(mountpoint),
71
+ "--dev",
72
+ "1",
73
+ "--storage",
74
+ "2",
75
+ "--allow-other",
76
+ "--usb-timeout",
77
+ "10",
78
+ path=f"{bin_dir}:/usr/bin:/bin",
79
+ )
80
+
81
+ assert result.returncode == 0, result.stderr
82
+ assert mountpoint.is_dir()
83
+ wait_for_path(log)
84
+ invocation = log.read_text(encoding="utf-8")
85
+ assert "-dev\n1\n-storage\n2\n-allow-other\n-usb-timeout\n10" in invocation
86
+ state = json.loads((tmp_path / "state" / "mounts.json").read_text(encoding="utf-8"))
87
+ assert str(mountpoint.resolve()) in state
88
+
89
+
90
+ def test_unmount_uses_available_command_and_cleans_metadata(tmp_path: Path) -> None:
91
+ bin_dir = tmp_path / "bin"
92
+ bin_dir.mkdir()
93
+ log = tmp_path / "umount.log"
94
+ make_executable(bin_dir / "umount", f"#!/bin/sh\nprintf '%s\\n' \"$@\" > {log}\nexit 0\n")
95
+ mountpoint = tmp_path / "Android"
96
+ mountpoint.mkdir()
97
+ state_dir = tmp_path / "state"
98
+ state_dir.mkdir()
99
+ (state_dir / "mounts.json").write_text(
100
+ json.dumps(
101
+ {
102
+ str(mountpoint.resolve()): {
103
+ "mountpoint": str(mountpoint.resolve()),
104
+ "pid": 123,
105
+ "command": ["go-mtpfs", str(mountpoint.resolve())],
106
+ "created_at": 1.0,
107
+ }
108
+ }
109
+ ),
110
+ encoding="utf-8",
111
+ )
112
+
113
+ result = run_maft(tmp_path, "unmount", str(mountpoint), path=f"{bin_dir}:/usr/bin:/bin")
114
+
115
+ assert result.returncode == 0, result.stderr
116
+ assert str(mountpoint) in log.read_text(encoding="utf-8")
117
+ state = json.loads((state_dir / "mounts.json").read_text(encoding="utf-8"))
118
+ assert state == {}
119
+
120
+
121
+ def test_cp_mv_rm_file_operations_against_mount_folder(tmp_path: Path) -> None:
122
+ mountpoint = tmp_path / "Android"
123
+ host = tmp_path / "host"
124
+ mountpoint.mkdir()
125
+ host.mkdir()
126
+ (host / "photo.jpg").write_text("photo", encoding="utf-8")
127
+
128
+ copy_to_device = run_maft(
129
+ tmp_path,
130
+ "cp",
131
+ "--mount",
132
+ str(mountpoint),
133
+ str(host / "photo.jpg"),
134
+ "DCIM/photo.jpg",
135
+ )
136
+ assert copy_to_device.returncode == 0, copy_to_device.stderr
137
+ assert (mountpoint / "DCIM" / "photo.jpg").read_text(encoding="utf-8") == "photo"
138
+
139
+ move_on_device = run_maft(
140
+ tmp_path,
141
+ "mv",
142
+ "--mount",
143
+ str(mountpoint),
144
+ "DCIM/photo.jpg",
145
+ "Pictures/photo.jpg",
146
+ )
147
+ assert move_on_device.returncode == 0, move_on_device.stderr
148
+ assert not (mountpoint / "DCIM" / "photo.jpg").exists()
149
+ assert (mountpoint / "Pictures" / "photo.jpg").exists()
150
+
151
+ copy_to_host = run_maft(
152
+ tmp_path,
153
+ "cp",
154
+ "--mount",
155
+ str(mountpoint),
156
+ "Pictures/photo.jpg",
157
+ str(host / "copy.jpg"),
158
+ )
159
+ assert copy_to_host.returncode == 0, copy_to_host.stderr
160
+ assert (host / "copy.jpg").read_text(encoding="utf-8") == "photo"
161
+
162
+ remove_file = run_maft(tmp_path, "rm", "--mount", str(mountpoint), "Pictures/photo.jpg")
163
+ assert remove_file.returncode == 0, remove_file.stderr
164
+ assert not (mountpoint / "Pictures" / "photo.jpg").exists()
165
+
166
+
167
+ def test_recursive_operations_and_path_safety(tmp_path: Path) -> None:
168
+ mountpoint = tmp_path / "Android"
169
+ host = tmp_path / "host"
170
+ mountpoint.mkdir()
171
+ host.mkdir()
172
+ (host / "album").mkdir()
173
+ (host / "album" / "a.txt").write_text("a", encoding="utf-8")
174
+
175
+ refused_directory_copy = run_maft(
176
+ tmp_path,
177
+ "cp",
178
+ "--mount",
179
+ str(mountpoint),
180
+ str(host / "album"),
181
+ "Albums/album",
182
+ )
183
+ assert refused_directory_copy.returncode == 1
184
+ assert "pass --recursive" in refused_directory_copy.stderr
185
+
186
+ copied_directory = run_maft(
187
+ tmp_path,
188
+ "cp",
189
+ "--mount",
190
+ str(mountpoint),
191
+ "--recursive",
192
+ str(host / "album"),
193
+ "Albums/album",
194
+ )
195
+ assert copied_directory.returncode == 0, copied_directory.stderr
196
+ assert (mountpoint / "Albums" / "album" / "a.txt").exists()
197
+
198
+ refused_escape = run_maft(tmp_path, "rm", "--mount", str(mountpoint), "../outside")
199
+ assert refused_escape.returncode == 1
200
+ assert "escapes mountpoint" in refused_escape.stderr
201
+
202
+ refused_directory_remove = run_maft(tmp_path, "rm", "--mount", str(mountpoint), "Albums")
203
+ assert refused_directory_remove.returncode == 1
204
+ assert "pass --recursive" in refused_directory_remove.stderr
205
+
206
+ removed_directory = run_maft(
207
+ tmp_path,
208
+ "rm",
209
+ "--mount",
210
+ str(mountpoint),
211
+ "--recursive",
212
+ "Albums",
213
+ )
214
+ assert removed_directory.returncode == 0, removed_directory.stderr
215
+ assert not (mountpoint / "Albums").exists()
216
+
217
+
218
+ def wait_for_path(path: Path) -> None:
219
+ deadline = time.monotonic() + 2
220
+ while time.monotonic() < deadline:
221
+ if path.exists():
222
+ return
223
+ time.sleep(0.01)
224
+ raise AssertionError(f"path was not created: {path}")
@@ -0,0 +1,8 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.12"
4
+
5
+ [[package]]
6
+ name = "mac-android-file-transfer"
7
+ version = "0.1.0"
8
+ source = { editable = "." }