mpytool 1.2.0__py3-none-any.whl → 2.1.0__py3-none-any.whl

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.
mpytool/mpytool.py CHANGED
@@ -1,37 +1,26 @@
1
1
  """MicroPython tool"""
2
2
 
3
+ import argparse as _argparse
4
+ import fnmatch as _fnmatch
5
+ import hashlib as _hashlib
6
+ import importlib.metadata as _metadata
3
7
  import os as _os
4
8
  import sys as _sys
5
- import argparse as _argparse
9
+ import time as _time
10
+
6
11
  import mpytool as _mpytool
7
12
  import mpytool.terminal as _terminal
8
- import mpytool.__about__ as _about
9
-
10
-
11
- class ParamsError(_mpytool.MpyError):
12
- """Timeout"""
13
+ import mpytool.utils as _utils
14
+ from mpytool.logger import SimpleColorLogger
13
15
 
16
+ try:
17
+ _about = _metadata.metadata("mpytool")
18
+ except _metadata.PackageNotFoundError:
19
+ _about = None
14
20
 
15
- class PathNotFound(_mpytool.MpyError):
16
- """File not found"""
17
- def __init__(self, file_name):
18
- self._file_name = file_name
19
- super().__init__(self.__str__())
20
21
 
21
- def __str__(self):
22
- return f"Path '{self._file_name}' was not found"
23
-
24
-
25
- class FileNotFound(PathNotFound):
26
- """Folder not found"""
27
- def __str__(self):
28
- return f"File '{self._file_name}' was not found"
29
-
30
-
31
- class DirNotFound(PathNotFound):
32
- """Folder not found"""
33
- def __str__(self):
34
- return f"Dir '{self._file_name}' was not found"
22
+ class ParamsError(_mpytool.MpyError):
23
+ """Invalid command parameters"""
35
24
 
36
25
 
37
26
  class MpyTool():
@@ -40,26 +29,428 @@ class MpyTool():
40
29
  TEE = '├─ '
41
30
  LAST = '└─ '
42
31
 
43
- def __init__(self, conn, log=None, verbose=0, exclude_dirs=None):
32
+ def __init__(
33
+ self, conn, log=None, verbose=None, exclude_dirs=None,
34
+ force=False, compress=None, chunk_size=None):
44
35
  self._conn = conn
45
- self._log = log
46
- self._verbose = verbose
47
- self._exclude_dirs = {'__pycache__', '.git', '.svn'}
36
+ self._log = log if log is not None else SimpleColorLogger()
37
+ self._verbose_out = verbose # None = no verbose output (API mode)
38
+ self._exclude_dirs = {'.*', '*.pyc'}
48
39
  if exclude_dirs:
49
40
  self._exclude_dirs.update(exclude_dirs)
50
- self._mpy = _mpytool.Mpy(conn, log=log)
41
+ self._mpy = _mpytool.Mpy(conn, log=self._log, chunk_size=chunk_size)
42
+ self._force = force
43
+ self._compress = compress
44
+ self._progress_total_files = 0
45
+ self._progress_current_file = 0
46
+ self._progress_src = ''
47
+ self._progress_dst = ''
48
+ self._progress_max_src_len = 0
49
+ self._progress_max_dst_len = 0
50
+ self._is_debug = getattr(self._log, '_loglevel', 1) >= 4
51
+ self._batch_mode = False
52
+ self._skipped_files = 0
53
+ self._stats_total_bytes = 0
54
+ self._stats_transferred_bytes = 0
55
+ self._stats_wire_bytes = 0 # Actual bytes sent over wire (with encoding)
56
+ self._stats_transferred_files = 0
57
+ self._stats_start_time = None
58
+ # Remote file info cache for batch operations
59
+ self._remote_file_cache = {} # {path: (size, hash) or None}
60
+
61
+ def _is_excluded(self, name):
62
+ """Check if name matches any exclude pattern (supports wildcards)"""
63
+ for pattern in self._exclude_dirs:
64
+ if _fnmatch.fnmatch(name, pattern):
65
+ return True
66
+ return False
67
+
68
+ @property
69
+ def _is_tty(self):
70
+ if self._verbose_out is None:
71
+ return False
72
+ return self._verbose_out._is_tty
73
+
74
+ @property
75
+ def _verbose(self):
76
+ if self._verbose_out is None:
77
+ return 0
78
+ return self._verbose_out._verbose_level
79
+
80
+ def verbose(self, msg, level=1, color='green', end='\n', overwrite=False):
81
+ if self._verbose_out is not None:
82
+ self._verbose_out.verbose(msg, level, color, end, overwrite)
83
+
84
+ def print_transfer_info(self):
85
+ """Print transfer settings (chunk size and compression)"""
86
+ chunk = self._mpy._detect_chunk_size()
87
+ chunk_str = f"{chunk // 1024}K" if chunk >= 1024 else str(chunk)
88
+ compress = self._mpy._detect_deflate() if self._compress is None else self._compress
89
+ compress_str = "on" if compress else "off"
90
+ self.verbose(f"COPY (chunk: {chunk_str}, compress: {compress_str})", 1)
91
+
92
+ @staticmethod
93
+ def _format_local_path(path):
94
+ """Format local path: relative from CWD, absolute if > 2 levels up"""
95
+ try:
96
+ rel_path = _os.path.relpath(path)
97
+ # Count leading ../
98
+ parts = rel_path.split(_os.sep)
99
+ up_count = 0
100
+ for part in parts:
101
+ if part == '..':
102
+ up_count += 1
103
+ else:
104
+ break
105
+ if up_count > 2:
106
+ return _os.path.abspath(path)
107
+ return rel_path
108
+ except ValueError:
109
+ # On Windows, relpath fails for different drives
110
+ return _os.path.abspath(path)
111
+
112
+ # Encoding info strings for alignment: (base64), (compressed), (base64, compressed), (unchanged)
113
+ _ENC_WIDTH = 22 # Length of longest: " (base64, compressed)"
114
+
115
+ def _format_encoding_info(self, encodings, pad=False):
116
+ """Format encoding info: (base64), (compressed), (base64, compressed)"""
117
+ if not encodings or encodings == {'raw'}:
118
+ return " " * self._ENC_WIDTH if pad else ""
119
+ types = sorted(e for e in encodings if e != 'raw')
120
+ if not types:
121
+ return " " * self._ENC_WIDTH if pad else ""
122
+ info = f" ({', '.join(types)})"
123
+ if pad:
124
+ return f"{info:<{self._ENC_WIDTH}}"
125
+ return info
126
+
127
+ def _format_line(self, status, total, encodings=None):
128
+ """Format progress/skip line: [2/5] 100% 24.1K source -> dest (base64)"""
129
+ size_str = self.format_size(total)
130
+ multi = self._progress_total_files > 1
131
+ prefix = f"[{self._progress_current_file}/{self._progress_total_files}]" if multi else ""
132
+ src_w = max(len(self._progress_src), self._progress_max_src_len)
133
+ dst_w = max(len(self._progress_dst), self._progress_max_dst_len)
134
+ enc = self._format_encoding_info(encodings, pad=multi) if encodings else (" " * self._ENC_WIDTH if multi else "")
135
+ return f"{prefix:>7} {status} {size_str:>5} {self._progress_src:<{src_w}} -> {self._progress_dst:<{dst_w}}{enc}"
136
+
137
+ def _format_progress_line(self, percent, total, encodings=None):
138
+ return self._format_line(f"{percent:3d}%", total, encodings)
139
+
140
+ def _format_skip_line(self, total):
141
+ return self._format_line("skip", total, {'unchanged'})
142
+
143
+ def _progress_callback(self, transferred, total):
144
+ """Callback for file transfer progress"""
145
+ percent = (transferred * 100 // total) if total > 0 else 100
146
+ line = self._format_progress_line(percent, total)
147
+ if self._is_debug:
148
+ # Debug mode: always newlines
149
+ self.verbose(line, color='cyan')
150
+ else:
151
+ # Normal mode: overwrite line
152
+ self.verbose(line, color='cyan', end='', overwrite=True)
153
+
154
+ def _progress_complete(self, total, encodings=None):
155
+ """Mark current file as complete"""
156
+ line = self._format_progress_line(100, total, encodings)
157
+ if self._is_debug:
158
+ # Already printed with newline in callback
159
+ pass
160
+ else:
161
+ self.verbose(line, color='cyan', overwrite=True)
162
+
163
+ def _set_progress_info(self, src, dst, is_src_remote, is_dst_remote):
164
+ """Set progress source and destination paths"""
165
+ if is_src_remote:
166
+ self._progress_src = ':' + src
167
+ else:
168
+ self._progress_src = self._format_local_path(src)
169
+ if is_dst_remote:
170
+ self._progress_dst = ':' + dst
171
+ else:
172
+ self._progress_dst = self._format_local_path(dst)
173
+ if len(self._progress_dst) > self._progress_max_dst_len:
174
+ self._progress_max_dst_len = len(self._progress_dst)
175
+
176
+ def _collect_local_paths(self, path):
177
+ """Collect all local file paths (formatted for display)"""
178
+ if _os.path.isfile(path):
179
+ return [self._format_local_path(path)]
180
+ paths = []
181
+ for root, dirs, files in _os.walk(path, topdown=True):
182
+ dirs[:] = [d for d in dirs if not self._is_excluded(d)]
183
+ files = [f for f in files if not self._is_excluded(f)]
184
+ paths.extend(self._format_local_path(_os.path.join(root, f)) for f in files)
185
+ return paths
186
+
187
+ def _prefetch_remote_info(self, dst_files):
188
+ """Prefetch remote file info (size and hash) for multiple files
189
+
190
+ Arguments:
191
+ dst_files: dict {remote_path: local_size} - sizes used to skip hash if mismatch
192
+
193
+ Uses batch call to reduce round-trips. Results are cached in _remote_file_cache.
194
+ """
195
+ if self._force or not dst_files:
196
+ return
197
+ files_to_fetch = {p: s for p, s in dst_files.items() if p not in self._remote_file_cache}
198
+ if not files_to_fetch:
199
+ return
200
+ self.verbose(f"Checking {len(files_to_fetch)} files...", 2)
201
+ result = self._mpy.fileinfo(files_to_fetch)
202
+ if result is None:
203
+ # hashlib not available - mark all as needing update
204
+ for path in files_to_fetch:
205
+ self._remote_file_cache[path] = None
206
+ else:
207
+ self._remote_file_cache.update(result)
208
+
209
+ def _collect_dst_files(self, src_path, dst_path, add_src_basename=True):
210
+ """Collect destination paths and local sizes for a local->remote copy operation
211
+
212
+ Arguments:
213
+ src_path: local source path (file or directory)
214
+ dst_path: remote destination base path
215
+ add_src_basename: if True, add source basename to dst_path (matching _put_dir behavior)
216
+
217
+ Returns:
218
+ dict {remote_path: local_file_size}
219
+ """
220
+ files = {}
221
+ if _os.path.isfile(src_path):
222
+ # For files, add basename if dst_path ends with /
223
+ basename = _os.path.basename(src_path)
224
+ if basename and not _os.path.basename(dst_path):
225
+ dst_path = _os.path.join(dst_path, basename)
226
+ files[dst_path] = _os.path.getsize(src_path)
227
+ elif _os.path.isdir(src_path):
228
+ # For directories, mimic _put_dir behavior
229
+ if add_src_basename:
230
+ basename = _os.path.basename(_os.path.abspath(src_path))
231
+ if basename:
232
+ dst_path = _os.path.join(dst_path, basename)
233
+ for root, dirs, filenames in _os.walk(src_path, topdown=True):
234
+ dirs[:] = [d for d in dirs if not self._is_excluded(d)]
235
+ filenames = [f for f in filenames if not self._is_excluded(f)]
236
+ rel_path = _os.path.relpath(root, src_path)
237
+ if rel_path == '.':
238
+ rel_path = ''
239
+ for file_name in filenames:
240
+ spath = _os.path.join(root, file_name)
241
+ dpath = _os.path.join(dst_path, rel_path, file_name) if rel_path else _os.path.join(dst_path, file_name)
242
+ files[dpath] = _os.path.getsize(spath)
243
+ return files
244
+
245
+ def _file_needs_update(self, local_data, remote_path):
246
+ """Check if local file differs from remote file
247
+
248
+ Returns True if file needs to be uploaded (different or doesn't exist)
249
+ """
250
+ if self._force:
251
+ return True
252
+ # Check cache first (populated by _prefetch_remote_info)
253
+ if remote_path in self._remote_file_cache:
254
+ cached = self._remote_file_cache[remote_path]
255
+ if cached is None:
256
+ return True # File doesn't exist or hashlib not available
257
+ remote_size, remote_hash = cached
258
+ local_size = len(local_data)
259
+ if local_size != remote_size:
260
+ return True # Different size
261
+ if remote_hash is None:
262
+ return True # Size matched but hash wasn't computed (shouldn't happen with prefetch)
263
+ local_hash = _hashlib.sha256(local_data).digest()
264
+ return local_hash != remote_hash
265
+ # Fallback to individual calls (for single file operations)
266
+ remote_size = self._mpy.stat(remote_path)
267
+ if remote_size is None or remote_size < 0:
268
+ return True # File doesn't exist or is a directory
269
+ local_size = len(local_data)
270
+ if local_size != remote_size:
271
+ return True # Different size
272
+ # Sizes match - check hash
273
+ local_hash = _hashlib.sha256(local_data).digest()
274
+ remote_hash = self._mpy.hashfile(remote_path)
275
+ if remote_hash is None:
276
+ return True # hashlib not available on device
277
+ return local_hash != remote_hash
278
+
279
+ def _collect_source_paths(self, commands):
280
+ """Collect source paths for a cp/put command (for alignment calculation)"""
281
+ paths = []
282
+ if not commands:
283
+ return paths
284
+ cmd = commands[0]
285
+ if cmd == 'cp' and len(commands) >= 3:
286
+ sources = commands[1:-1]
287
+ for src in sources:
288
+ src_is_remote = src.startswith(':')
289
+ src_path = src[1:] if src_is_remote else src
290
+ if not src_path:
291
+ src_path = '/'
292
+ src_path = src_path.rstrip('/') or '/'
293
+ if src_is_remote:
294
+ paths.extend(self._collect_remote_paths(src_path))
295
+ else:
296
+ paths.extend(self._collect_local_paths(src_path))
297
+ elif cmd == 'put' and len(commands) >= 2:
298
+ src_path = commands[1]
299
+ paths.extend(self._collect_local_paths(src_path))
300
+ return paths
301
+
302
+ def _collect_remote_paths(self, path):
303
+ """Collect all remote file paths (formatted for display)"""
304
+ paths = []
305
+ stat = self._mpy.stat(path)
306
+ if stat is None:
307
+ return paths
308
+ if stat >= 0: # file
309
+ paths.append(':' + path)
310
+ else: # directory
311
+ entries = self._mpy.ls(path)
312
+ for name, size in entries:
313
+ entry_path = path.rstrip('/') + '/' + name
314
+ if size is None: # directory
315
+ paths.extend(self._collect_remote_paths(entry_path))
316
+ else: # file
317
+ paths.append(':' + entry_path)
318
+ return paths
319
+
320
+ def _collect_destination_paths(self, commands):
321
+ """Collect formatted destination paths for a cp/put command"""
322
+ if not commands:
323
+ return []
324
+ cmd = commands[0]
325
+ if cmd == 'cp' and len(commands) >= 3:
326
+ # Filter out flags
327
+ args = [a for a in commands[1:] if not a.startswith('-')]
328
+ if len(args) < 2:
329
+ return []
330
+ sources, dest = args[:-1], args[-1]
331
+ dest_is_remote = dest.startswith(':')
332
+ dest_path = (dest[1:] or '/') if dest_is_remote else dest
333
+ dest_is_dir = dest_path.endswith('/')
334
+ dst_paths = []
335
+ for src in sources:
336
+ src_is_remote = src.startswith(':')
337
+ src_path = ((src[1:] or '/') if src_is_remote else src).rstrip('/') or '/'
338
+ copy_contents = src.endswith('/')
339
+ if dest_is_remote and not src_is_remote:
340
+ # local -> remote: reuse _collect_dst_files
341
+ if _os.path.exists(src_path):
342
+ files = self._collect_dst_files(src_path, dest_path.rstrip('/') or '/', not copy_contents)
343
+ dst_paths.extend(':' + p for p in files)
344
+ elif not dest_is_remote and src_is_remote:
345
+ # remote -> local
346
+ dst_paths.extend(self._collect_remote_to_local_dst(src_path, dest_path, dest_is_dir, copy_contents))
347
+ elif dest_is_remote and src_is_remote:
348
+ # remote -> remote (file only)
349
+ stat = self._mpy.stat(src_path)
350
+ if stat is not None and stat >= 0:
351
+ basename = src_path.split('/')[-1]
352
+ dst_paths.append(':' + (dest_path + basename if dest_is_dir else dest_path))
353
+ return dst_paths
354
+ elif cmd == 'put' and len(commands) >= 2:
355
+ src_path = commands[1]
356
+ dst_path = commands[2] if len(commands) > 2 else '/'
357
+ if _os.path.exists(src_path):
358
+ files = self._collect_dst_files(src_path, dst_path.rstrip('/') or '/', add_src_basename=True)
359
+ return [':' + p for p in files]
360
+ return []
361
+
362
+ def _collect_remote_to_local_dst(self, src_path, dest_path, dest_is_dir, copy_contents):
363
+ """Collect destination paths for remote->local copy"""
364
+ stat = self._mpy.stat(src_path)
365
+ if stat is None:
366
+ return []
367
+ base_dst = dest_path.rstrip('/') or '.'
368
+ if dest_is_dir and not copy_contents and src_path != '/':
369
+ base_dst = _os.path.join(base_dst, src_path.split('/')[-1])
370
+ if stat >= 0: # file
371
+ if _os.path.isdir(base_dst) or dest_is_dir:
372
+ return [self._format_local_path(_os.path.join(base_dst, src_path.split('/')[-1]))]
373
+ return [self._format_local_path(base_dst)]
374
+ return self._collect_remote_dir_dst(src_path, base_dst)
375
+
376
+ def _collect_remote_dir_dst(self, src_path, base_dst):
377
+ """Collect local destination paths for remote directory download"""
378
+ paths = []
379
+ for name, size in self._mpy.ls(src_path):
380
+ entry_src = src_path.rstrip('/') + '/' + name
381
+ entry_dst = _os.path.join(base_dst, name)
382
+ if size is None: # directory
383
+ paths.extend(self._collect_remote_dir_dst(entry_src, entry_dst))
384
+ else: # file
385
+ paths.append(self._format_local_path(entry_dst))
386
+ return paths
387
+
388
+ def count_files_for_command(self, commands):
389
+ """Count files that would be transferred by cp/put command.
390
+ Returns (is_copy_command, file_count, source_paths, dest_paths)"""
391
+ src_paths = self._collect_source_paths(commands)
392
+ if src_paths:
393
+ dst_paths = self._collect_destination_paths(commands)
394
+ return True, len(src_paths), src_paths, dst_paths
395
+ return False, 0, [], []
51
396
 
52
- def verbose(self, msg, level=1):
53
- if self._verbose >= level:
54
- print(msg, file=_sys.stderr)
397
+ def set_batch_progress(self, total_files, max_src_len=0, max_dst_len=0):
398
+ """Set batch progress for consecutive copy commands"""
399
+ self._progress_total_files = total_files
400
+ self._progress_current_file = 0
401
+ self._progress_max_src_len = max_src_len
402
+ self._progress_max_dst_len = max_dst_len
403
+ self._batch_mode = True
404
+ self._stats_total_bytes = 0
405
+ self._stats_transferred_bytes = 0
406
+ self._stats_wire_bytes = 0
407
+ self._stats_transferred_files = 0
408
+ self._skipped_files = 0
409
+ self._stats_start_time = _time.time()
410
+
411
+ def reset_batch_progress(self):
412
+ """Reset batch progress mode"""
413
+ self._batch_mode = False
414
+ self._progress_total_files = 0
415
+ self._progress_current_file = 0
416
+ self._progress_max_src_len = 0
417
+ self._progress_max_dst_len = 0
418
+ self._remote_file_cache.clear()
419
+
420
+ def print_copy_summary(self):
421
+ """Print summary after copy operation"""
422
+ if self._stats_start_time is None:
423
+ return
424
+ elapsed = _time.time() - self._stats_start_time
425
+ total = self._stats_total_bytes
426
+ transferred = self._stats_transferred_bytes
427
+ wire = self._stats_wire_bytes
428
+ total_files = self._stats_transferred_files + self._skipped_files
429
+ parts = []
430
+ parts.append(f"{self.format_size(transferred).strip()}")
431
+ if elapsed > 0:
432
+ speed = transferred / elapsed
433
+ parts.append(f"{self.format_size(speed).strip()}/s")
434
+ parts.append(f"{elapsed:.1f}s")
435
+ # Combined speedup: total file size vs actual wire bytes
436
+ # Includes savings from: skipped files, base64 encoding, compression
437
+ if wire > 0 and total > wire:
438
+ speedup = total / wire
439
+ parts.append(f"speedup {speedup:.1f}x")
440
+ summary = " ".join(parts)
441
+ if self._skipped_files > 0:
442
+ file_info = f"{self._stats_transferred_files} transferred, {self._skipped_files} skipped"
443
+ else:
444
+ file_info = f"{total_files} files"
445
+ self.verbose(f" {summary} ({file_info})", color='green')
55
446
 
56
447
  def cmd_ls(self, dir_name):
57
448
  result = self._mpy.ls(dir_name)
58
449
  for name, size in result:
59
450
  if size is not None:
60
- print(f'{size:8d} {name}')
451
+ print(f'{self.format_size(size):>9} {name}')
61
452
  else:
62
- print(f'{"":8} {name}/')
453
+ print(f'{"":9} {name}/')
63
454
 
64
455
  @classmethod
65
456
  def print_tree(cls, tree, prefix='', print_size=True, first=True, last=True):
@@ -77,7 +468,7 @@ class MpyTool():
77
468
  sufix = '/'
78
469
  line = ''
79
470
  if print_size:
80
- line += f'{size:8d} '
471
+ line += f'{cls.format_size(size):>9} '
81
472
  line += prefix + this_prefix + name + sufix
82
473
  print(line)
83
474
  if not sub_tree:
@@ -108,51 +499,76 @@ class MpyTool():
108
499
 
109
500
  def cmd_get(self, *file_names):
110
501
  for file_name in file_names:
111
- self.verbose(f"GET: {file_name}")
502
+ self.verbose(f"GET: {file_name}", 2)
112
503
  data = self._mpy.get(file_name)
113
504
  print(data.decode('utf-8'))
114
505
 
115
- def _put_dir(self, src_path, dst_path):
116
- basename = _os.path.basename(src_path)
506
+ def _upload_file(self, data, src_path, dst_path, show_progress):
507
+ """Upload file data to device with stats tracking and progress display"""
508
+ file_size = len(data)
509
+ self._stats_total_bytes += file_size
510
+ if not self._file_needs_update(data, dst_path):
511
+ self._skipped_files += 1
512
+ if show_progress and self._verbose >= 1:
513
+ self._progress_current_file += 1
514
+ self._set_progress_info(src_path, dst_path, False, True)
515
+ self.verbose(self._format_skip_line(file_size), color='yellow')
516
+ return False # skipped
517
+ self._stats_transferred_bytes += file_size
518
+ self._stats_transferred_files += 1
519
+ if show_progress and self._verbose >= 1:
520
+ self._progress_current_file += 1
521
+ self._set_progress_info(src_path, dst_path, False, True)
522
+ encodings, wire = self._mpy.put(data, dst_path, self._progress_callback, self._compress)
523
+ self._stats_wire_bytes += wire
524
+ self._progress_complete(file_size, encodings)
525
+ else:
526
+ _, wire = self._mpy.put(data, dst_path, compress=self._compress)
527
+ self._stats_wire_bytes += wire
528
+ return True # uploaded
529
+
530
+ def _put_dir(self, src_path, dst_path, show_progress=True):
531
+ basename = _os.path.basename(_os.path.abspath(src_path))
117
532
  if basename:
118
533
  dst_path = _os.path.join(dst_path, basename)
119
- self.verbose(f"PUT_DIR: {src_path} -> {dst_path}")
120
- for path, _dirs, files in _os.walk(src_path):
121
- basename = _os.path.basename(path)
122
- if basename in self._exclude_dirs:
534
+ self.verbose(f"PUT DIR: {src_path} -> {dst_path}", 2)
535
+ created_dirs = set()
536
+ for path, dirs, files in _os.walk(src_path, topdown=True):
537
+ dirs[:] = [d for d in dirs if not self._is_excluded(d)]
538
+ files = [f for f in files if not self._is_excluded(f)]
539
+ if not files:
123
540
  continue
124
541
  rel_path = _os.path.relpath(path, src_path)
125
- if rel_path == '.':
126
- rel_path = ''
127
- rel_path = _os.path.join(dst_path, rel_path)
128
- if rel_path:
129
- self.verbose(f'mkdir: {rel_path}', 2)
542
+ rel_path = _os.path.join(dst_path, '' if rel_path == '.' else rel_path)
543
+ if rel_path and rel_path not in created_dirs:
544
+ self.verbose(f'MKDIR: {rel_path}', 2)
130
545
  self._mpy.mkdir(rel_path)
546
+ created_dirs.add(rel_path)
131
547
  for file_name in files:
132
548
  spath = _os.path.join(path, file_name)
133
- dpath = _os.path.join(rel_path, file_name)
134
- self.verbose(f" {dpath}")
135
- with open(spath, 'rb') as src_file:
136
- data = src_file.read()
137
- self._mpy.put(data, dpath)
549
+ with open(spath, 'rb') as f:
550
+ self._upload_file(f.read(), spath, _os.path.join(rel_path, file_name), show_progress)
138
551
 
139
- def _put_file(self, src_path, dst_path):
552
+ def _put_file(self, src_path, dst_path, show_progress=True):
140
553
  basename = _os.path.basename(src_path)
141
554
  if basename and not _os.path.basename(dst_path):
142
555
  dst_path = _os.path.join(dst_path, basename)
143
- self.verbose(f"PUT_FILE: {src_path} -> {dst_path}")
144
- path = _os.path.dirname(dst_path)
145
- result = self._mpy.stat(path)
146
- if result is None:
147
- self._mpy.mkdir(path)
148
- elif result >= 0:
149
- raise _mpytool.MpyError(
150
- f'Error creating file under file: {path}')
151
- with open(src_path, 'rb') as src_file:
152
- data = src_file.read()
153
- self._mpy.put(data, dst_path)
556
+ self.verbose(f"PUT FILE: {src_path} -> {dst_path}", 2)
557
+ with open(src_path, 'rb') as f:
558
+ data = f.read()
559
+ parent = _os.path.dirname(dst_path)
560
+ if parent:
561
+ stat = self._mpy.stat(parent)
562
+ if stat is None:
563
+ self._mpy.mkdir(parent)
564
+ elif stat >= 0:
565
+ raise _mpytool.MpyError(f'Error creating file under file: {parent}')
566
+ self._upload_file(data, src_path, dst_path, show_progress)
154
567
 
155
568
  def cmd_put(self, src_path, dst_path):
569
+ if self._verbose >= 1 and not self._batch_mode:
570
+ self._progress_total_files = len(self._collect_local_paths(src_path))
571
+ self._progress_current_file = 0
156
572
  if _os.path.isdir(src_path):
157
573
  self._put_dir(src_path, dst_path)
158
574
  elif _os.path.isfile(src_path):
@@ -160,40 +576,584 @@ class MpyTool():
160
576
  else:
161
577
  raise ParamsError(f'No file or directory to upload: {src_path}')
162
578
 
579
+ def _get_file(self, src_path, dst_path, show_progress=True):
580
+ """Download single file from device"""
581
+ self.verbose(f"GET FILE: {src_path} -> {dst_path}", 2)
582
+ dst_dir = _os.path.dirname(dst_path)
583
+ if dst_dir and not _os.path.exists(dst_dir):
584
+ _os.makedirs(dst_dir)
585
+ if show_progress and self._verbose >= 1:
586
+ self._progress_current_file += 1
587
+ self._set_progress_info(src_path, dst_path, True, False)
588
+ data = self._mpy.get(src_path, self._progress_callback)
589
+ self._progress_complete(len(data))
590
+ else:
591
+ data = self._mpy.get(src_path)
592
+ file_size = len(data)
593
+ self._stats_total_bytes += file_size
594
+ self._stats_transferred_bytes += file_size
595
+ self._stats_transferred_files += 1
596
+ with open(dst_path, 'wb') as dst_file:
597
+ dst_file.write(data)
598
+
599
+ def _get_dir(self, src_path, dst_path, copy_contents=False, show_progress=True):
600
+ """Download directory from device"""
601
+ if not copy_contents:
602
+ basename = src_path.rstrip('/').split('/')[-1]
603
+ if basename:
604
+ dst_path = _os.path.join(dst_path, basename)
605
+ self.verbose(f"GET DIR: {src_path} -> {dst_path}", 2)
606
+ if not _os.path.exists(dst_path):
607
+ _os.makedirs(dst_path)
608
+ entries = self._mpy.ls(src_path)
609
+ for name, size in entries:
610
+ src_entry = src_path.rstrip('/') + '/' + name
611
+ dst_entry = _os.path.join(dst_path, name)
612
+ if size is None: # directory
613
+ self._get_dir(src_entry, dst_entry, copy_contents=True, show_progress=show_progress)
614
+ else: # file
615
+ self._get_file(src_entry, dst_entry, show_progress=show_progress)
616
+
617
+ def _cp_local_to_remote(self, src_path, dst_path, dst_is_dir):
618
+ """Upload local file/dir to device"""
619
+ src_is_dir = _os.path.isdir(src_path)
620
+ copy_contents = src_path.endswith('/')
621
+ src_path = src_path.rstrip('/')
622
+ if not _os.path.exists(src_path):
623
+ raise ParamsError(f'Source not found: {src_path}')
624
+ if dst_is_dir:
625
+ if not copy_contents:
626
+ basename = _os.path.basename(src_path)
627
+ dst_path = dst_path + basename
628
+ dst_path = dst_path.rstrip('/')
629
+ if src_is_dir:
630
+ if copy_contents:
631
+ for item in _os.listdir(src_path):
632
+ if self._is_excluded(item):
633
+ continue
634
+ item_src = _os.path.join(src_path, item)
635
+ if _os.path.isdir(item_src):
636
+ self._put_dir(item_src, dst_path)
637
+ else:
638
+ self._put_file(item_src, dst_path + '/')
639
+ else:
640
+ self._put_dir(src_path, _os.path.dirname(dst_path) or '/')
641
+ else:
642
+ self._put_file(src_path, dst_path)
643
+
644
+ def _cp_remote_to_local(self, src_path, dst_path, dst_is_dir):
645
+ """Download file/dir from device to local"""
646
+ copy_contents = src_path.endswith('/')
647
+ src_path = src_path.rstrip('/') or '/'
648
+ stat = self._mpy.stat(src_path)
649
+ if stat is None:
650
+ raise ParamsError(f'Source not found on device: {src_path}')
651
+ src_is_dir = (stat == -1)
652
+ if dst_is_dir:
653
+ if not _os.path.exists(dst_path):
654
+ _os.makedirs(dst_path)
655
+ if not copy_contents and src_path != '/':
656
+ basename = src_path.split('/')[-1]
657
+ dst_path = _os.path.join(dst_path, basename)
658
+ if src_is_dir:
659
+ self._get_dir(src_path, dst_path, copy_contents=copy_contents)
660
+ else:
661
+ if _os.path.isdir(dst_path):
662
+ basename = src_path.split('/')[-1]
663
+ dst_path = _os.path.join(dst_path, basename)
664
+ self._get_file(src_path, dst_path)
665
+
666
+ def _cp_remote_to_remote(self, src_path, dst_path, dst_is_dir):
667
+ """Copy file on device"""
668
+ src_path = src_path.rstrip('/') or '/'
669
+ stat = self._mpy.stat(src_path)
670
+ if stat is None:
671
+ raise ParamsError(f'Source not found on device: {src_path}')
672
+ if stat == -1:
673
+ raise ParamsError('Remote-to-remote directory copy not supported yet')
674
+ if dst_is_dir:
675
+ basename = src_path.split('/')[-1]
676
+ dst_path = dst_path + basename
677
+ self.verbose(f"COPY: {src_path} -> {dst_path}", 2)
678
+ if self._verbose >= 1:
679
+ self._progress_current_file += 1
680
+ self._set_progress_info(src_path, dst_path, True, True)
681
+ data = self._mpy.get(src_path, self._progress_callback)
682
+ encodings, wire = self._mpy.put(data, dst_path, compress=self._compress)
683
+ self._stats_wire_bytes += wire
684
+ self._progress_complete(len(data), encodings)
685
+ else:
686
+ data = self._mpy.get(src_path)
687
+ _, wire = self._mpy.put(data, dst_path, compress=self._compress)
688
+ self._stats_wire_bytes += wire
689
+ file_size = len(data)
690
+ self._stats_total_bytes += file_size
691
+ self._stats_transferred_bytes += file_size
692
+ self._stats_transferred_files += 1
693
+
694
+ def cmd_cp(self, *args):
695
+ """Copy files between local and device"""
696
+ # Parse flags: -f/--force, -z/--compress, -Z/--no-compress
697
+ args = list(args)
698
+ force = None
699
+ compress = None # None = use global setting
700
+ filtered_args = []
701
+ for a in args:
702
+ if a == '--force':
703
+ force = True
704
+ elif a == '--compress':
705
+ compress = True
706
+ elif a == '--no-compress':
707
+ compress = False
708
+ elif a.startswith('-') and not a.startswith('--') and len(a) > 1:
709
+ # Handle combined short flags like -fz, -fZ
710
+ flags = a[1:]
711
+ if 'f' in flags:
712
+ force = True
713
+ if 'z' in flags:
714
+ compress = True
715
+ if 'Z' in flags:
716
+ compress = False
717
+ remaining = flags.replace('f', '').replace('z', '').replace('Z', '')
718
+ if remaining:
719
+ filtered_args.append('-' + remaining)
720
+ else:
721
+ filtered_args.append(a)
722
+ args = filtered_args
723
+ if len(args) < 2:
724
+ raise ParamsError('cp requires source and destination')
725
+ # Save and set flags for this command
726
+ saved_force = self._force
727
+ saved_compress = self._compress
728
+ if force is not None:
729
+ self._force = force
730
+ if compress is not None:
731
+ self._compress = compress
732
+ try:
733
+ self._cmd_cp_impl(args)
734
+ finally:
735
+ self._force = saved_force
736
+ self._compress = saved_compress
737
+
738
+ def _cmd_cp_impl(self, args):
739
+ sources = list(args[:-1])
740
+ dest = args[-1]
741
+ dest_is_remote = dest.startswith(':')
742
+ dest_path = dest[1:] if dest_is_remote else dest
743
+ if not dest_path:
744
+ dest_path = '/'
745
+ dest_is_dir = dest_path.endswith('/')
746
+ if len(sources) > 1 and not dest_is_dir:
747
+ raise ParamsError('multiple sources require destination directory (ending with /)')
748
+ if self._verbose >= 1 and not self._batch_mode:
749
+ total_files = 0
750
+ for src in sources:
751
+ src_is_remote = src.startswith(':')
752
+ src_path = src[1:] if src_is_remote else src
753
+ if not src_path:
754
+ src_path = '/'
755
+ src_path_clean = src_path.rstrip('/') or '/'
756
+ if src_is_remote:
757
+ total_files += len(self._collect_remote_paths(src_path_clean))
758
+ else:
759
+ total_files += len(self._collect_local_paths(src_path_clean))
760
+ self._progress_total_files = total_files
761
+ self._progress_current_file = 0
762
+ all_dst_files = {}
763
+ if dest_is_remote:
764
+ for src in sources:
765
+ if not src.startswith(':'): # local source
766
+ copy_contents = src.endswith('/')
767
+ src_path = src.rstrip('/')
768
+ if _os.path.exists(src_path):
769
+ add_basename = not copy_contents
770
+ all_dst_files.update(self._collect_dst_files(src_path, dest_path.rstrip('/') or '/', add_basename))
771
+ if all_dst_files and not self._batch_mode:
772
+ self._progress_max_dst_len = max(len(':' + p) for p in all_dst_files)
773
+ if not self._force:
774
+ self._prefetch_remote_info(all_dst_files)
775
+ for src in sources:
776
+ src_is_remote = src.startswith(':')
777
+ src_path = src[1:] if src_is_remote else src
778
+ if not src_path:
779
+ src_path = '/'
780
+ if src_is_remote and dest_is_remote:
781
+ self._cp_remote_to_remote(src_path, dest_path, dest_is_dir)
782
+ elif src_is_remote:
783
+ self._cp_remote_to_local(src_path, dest_path, dest_is_dir)
784
+ elif dest_is_remote:
785
+ self._cp_local_to_remote(src_path, dest_path, dest_is_dir)
786
+ else:
787
+ self.verbose(f"skip local-to-local: {src} -> {dest}", 2)
788
+
789
+ def cmd_mv(self, *args):
790
+ """Move/rename files on device"""
791
+ if len(args) < 2:
792
+ raise ParamsError('mv requires source and destination')
793
+ sources = list(args[:-1])
794
+ dest = args[-1]
795
+ if not dest.startswith(':'):
796
+ raise ParamsError('mv destination must be device path (: prefix)')
797
+ for src in sources:
798
+ if not src.startswith(':'):
799
+ raise ParamsError('mv source must be device path (: prefix)')
800
+ dest_path = dest[1:] or '/'
801
+ dest_is_dir = dest_path.endswith('/')
802
+ if len(sources) > 1 and not dest_is_dir:
803
+ raise ParamsError('multiple sources require destination directory (ending with /)')
804
+ self._mpy.import_module('os')
805
+ for src in sources:
806
+ src_path = src[1:]
807
+ stat = self._mpy.stat(src_path)
808
+ if stat is None:
809
+ raise ParamsError(f'Source not found on device: {src_path}')
810
+ if dest_is_dir:
811
+ dst_dir = dest_path.rstrip('/')
812
+ if dst_dir and self._mpy.stat(dst_dir) is None:
813
+ self._mpy.mkdir(dst_dir)
814
+ basename = src_path.rstrip('/').split('/')[-1]
815
+ final_dest = dest_path + basename
816
+ else:
817
+ final_dest = dest_path
818
+ self.verbose(f"MV: {src_path} -> {final_dest}", 1)
819
+ self._mpy.rename(src_path, final_dest)
820
+
163
821
  def cmd_mkdir(self, *dir_names):
164
822
  for dir_name in dir_names:
165
- self.verbose(f"MKDIR: {dir_name}")
823
+ self.verbose(f"MKDIR: {dir_name}", 1)
166
824
  self._mpy.mkdir(dir_name)
167
825
 
168
826
  def cmd_delete(self, *file_names):
169
827
  for file_name in file_names:
170
- self.verbose(f"DELETE: {file_name}")
171
- self._mpy.delete(file_name)
828
+ contents_only = file_name.endswith('/')
829
+ path = file_name.rstrip('/') or '/'
830
+ if contents_only:
831
+ self.verbose(f"DELETE contents: {path}", 1)
832
+ entries = self._mpy.ls(path)
833
+ for name, size in entries:
834
+ entry_path = path + '/' + name if path != '/' else '/' + name
835
+ self.verbose(f" {entry_path}", 1)
836
+ self._mpy.delete(entry_path)
837
+ else:
838
+ self.verbose(f"DELETE: {path}", 1)
839
+ self._mpy.delete(path)
172
840
 
173
- def cmd_follow(self):
174
- self.verbose("FOLLOW:")
841
+ def cmd_monitor(self):
842
+ self.verbose("MONITOR (Ctrl+C to stop)", 1)
175
843
  try:
176
844
  while True:
177
845
  line = self._conn.read_line()
178
846
  line = line.decode('utf-8', 'backslashreplace')
179
847
  print(line)
180
848
  except KeyboardInterrupt:
849
+ self.verbose('', level=0, overwrite=True) # newline after ^C
850
+ except (_mpytool.ConnError, OSError) as err:
181
851
  if self._log:
182
- self._log.warning(' Exiting..')
183
- return
852
+ self._log.error(err)
184
853
 
185
854
  def cmd_repl(self):
186
- self.verbose("REPL:")
187
855
  self._mpy.comm.exit_raw_repl()
188
856
  if not _terminal.AVAILABLE:
189
857
  self._log.error("REPL not available on this platform")
190
- print("Entering REPL mode, to exit press CTRL + ]")
191
- terminal = _terminal.Terminal()
192
- terminal.run(self._conn)
193
- if self._log:
194
- self._log.warning(' Exiting..')
858
+ return
859
+ self.verbose("REPL (Ctrl+] to exit)", 1)
860
+ terminal = _terminal.Terminal(self._conn, self._log)
861
+ terminal.run()
862
+ self._log.info('Exiting..')
195
863
 
196
- def process_commands(self, commands):
864
+ def cmd_exec(self, code):
865
+ self.verbose(f"EXEC: {code}", 1)
866
+ result = self._mpy.comm.exec(code)
867
+ if result:
868
+ print(result.decode('utf-8', 'backslashreplace'), end='')
869
+
870
+ @staticmethod
871
+ def format_size(size):
872
+ """Format size in bytes to human readable format (like ls -h)"""
873
+ if size < 1024:
874
+ return f"{int(size)}B"
875
+ for unit in ('K', 'M', 'G', 'T'):
876
+ size /= 1024
877
+ if size < 10:
878
+ return f"{size:.2f}{unit}"
879
+ if size < 100:
880
+ return f"{size:.1f}{unit}"
881
+ if size < 1024 or unit == 'T':
882
+ return f"{size:.0f}{unit}"
883
+ return f"{size:.0f}T"
884
+
885
+ def cmd_paths(self, dir_name='.'):
886
+ """Print all paths (for shell completion) - undocumented"""
887
+ tree = self._mpy.tree(dir_name)
888
+ self._print_paths(tree, '')
889
+
890
+ def _print_paths(self, entry, prefix):
891
+ """Recursively print paths from tree structure"""
892
+ name, size, children = entry
893
+ if name in ('.', './'):
894
+ path = ''
895
+ else:
896
+ path = prefix + name
897
+ if children is None:
898
+ # File
899
+ print(path)
900
+ else:
901
+ # Directory
902
+ if path:
903
+ print(path + '/')
904
+ for child in children:
905
+ self._print_paths(child, path + '/' if path else '')
906
+
907
+ def cmd_ota(self, firmware_path):
908
+ """OTA firmware update from local .app-bin file"""
909
+ self.verbose("OTA UPDATE", 1)
910
+ if not _os.path.isfile(firmware_path):
911
+ raise ParamsError(f"Firmware file not found: {firmware_path}")
912
+
913
+ with open(firmware_path, 'rb') as f:
914
+ firmware = f.read()
915
+
916
+ fw_size = len(firmware)
917
+ self.verbose(f" Firmware: {self.format_size(fw_size)}", 1)
918
+ info = self._mpy.partitions()
919
+ if not info['next_ota']:
920
+ raise _mpytool.MpyError("OTA not available (no OTA partitions)")
921
+
922
+ self.verbose(f" Target: {info['next_ota']} ({self.format_size(info['next_ota_size'])})", 1)
923
+ use_compress = self._mpy._detect_deflate()
924
+ chunk_size = self._mpy._detect_chunk_size()
925
+ chunk_str = f"{chunk_size // 1024}K" if chunk_size >= 1024 else str(chunk_size)
926
+ self.verbose(f" Writing (chunk: {chunk_str}, compress: {'on' if use_compress else 'off'})...", 1)
927
+ start_time = _time.time()
928
+
929
+ def progress_callback(transferred, total, wire_bytes):
930
+ if self._verbose >= 1:
931
+ pct = transferred * 100 // total
932
+ elapsed = _time.time() - start_time
933
+ speed = transferred / elapsed / 1024 if elapsed > 0 else 0
934
+ line = f" Writing: {pct:3d}% {self.format_size(transferred):>6} / {self.format_size(total)} {speed:.1f} KB/s"
935
+ self.verbose(line, color='cyan', end='', overwrite=True)
936
+
937
+ result = self._mpy.ota_write(firmware, progress_callback, self._compress)
938
+ elapsed = _time.time() - start_time
939
+ speed = fw_size / elapsed / 1024 if elapsed > 0 else 0
940
+ ratio = fw_size / result['wire_bytes'] if result['wire_bytes'] > 0 else 1
941
+ self.verbose(
942
+ f" Writing: 100% {self.format_size(fw_size):>6} {elapsed:.1f}s {speed:.1f} KB/s ratio {ratio:.2f}x",
943
+ color='cyan', overwrite=True
944
+ )
945
+
946
+ self.verbose(f" OTA complete! Use 'mreset' to boot into new firmware.", 1, color='green')
947
+
948
+ def cmd_flash(self):
949
+ """Show flash information (auto-detect platform)"""
950
+ self.verbose("FLASH", 2)
951
+ platform = self._mpy.platform()['platform']
952
+
953
+ if platform == 'rp2':
954
+ self._cmd_flash_rp2()
955
+ elif platform == 'esp32':
956
+ self._cmd_flash_esp32()
957
+ else:
958
+ raise _mpytool.MpyError(f"Flash info not supported for platform: {platform}")
959
+
960
+ def _cmd_flash_rp2(self):
961
+ """Show RP2 flash information"""
962
+ info = self._mpy.flash_info()
963
+ print(f"Platform: RP2")
964
+ print(f"Flash size: {self.format_size(info['size'])}")
965
+ print(f"Block size: {info['block_size']} bytes")
966
+ print(f"Block count: {info['block_count']}")
967
+ fs_line = f"Filesystem: {info['filesystem']}"
968
+ # For FAT, show cluster size if detected from magic
969
+ if info.get('fs_block_size'):
970
+ fs_line += f" (cluster: {self.format_size(info['fs_block_size'])})"
971
+ if info['filesystem'] == 'unknown' and info.get('magic'):
972
+ magic_hex = ' '.join(f'{b:02x}' for b in info['magic'])
973
+ fs_line += f" (magic: {magic_hex})"
974
+ print(fs_line)
975
+
976
+ def _make_progress(self, action, label=None):
977
+ """Create progress callback for flash operations"""
978
+ def progress(transferred, total, *_):
979
+ if self._verbose >= 1:
980
+ pct = (transferred / total * 100) if total > 0 else 0
981
+ prefix = f"{action} {label}" if label else action
982
+ self.verbose(
983
+ f" {prefix}: {pct:.0f}% {self.format_size(transferred)} / {self.format_size(total)}",
984
+ color='cyan', end='', overwrite=True)
985
+ return progress
986
+
987
+ def cmd_flash_read(self, dest_path, label=None):
988
+ """Read flash/partition content to file"""
989
+ if label:
990
+ self.verbose(f"FLASH READ {label} -> {dest_path}", 1)
991
+ else:
992
+ self.verbose(f"FLASH READ -> {dest_path}", 1)
993
+
994
+ data = self._mpy.flash_read(label=label, progress_callback=self._make_progress("reading", label))
995
+
996
+ if self._verbose >= 1:
997
+ print() # newline after progress
998
+
999
+ with open(dest_path, 'wb') as f:
1000
+ f.write(data)
1001
+
1002
+ self.verbose(f" saved {self.format_size(len(data))} to {dest_path}", 1, color='green')
1003
+
1004
+ def cmd_flash_write(self, src_path, label=None):
1005
+ """Write file content to flash/partition"""
1006
+ if label:
1007
+ self.verbose(f"FLASH WRITE {src_path} -> {label}", 1)
1008
+ else:
1009
+ self.verbose(f"FLASH WRITE {src_path}", 1)
1010
+
1011
+ with open(src_path, 'rb') as f:
1012
+ data = f.read()
1013
+
1014
+ result = self._mpy.flash_write(
1015
+ data, label=label,
1016
+ progress_callback=self._make_progress("writing", label),
1017
+ compress=self._compress)
1018
+
1019
+ if self._verbose >= 1:
1020
+ print() # newline after progress
1021
+
1022
+ target = label or "flash"
1023
+ comp_info = " (compressed)" if result.get('compressed') else ""
1024
+ self.verbose(f" wrote {self.format_size(result['written'])} to {target}{comp_info}", 1, color='green')
1025
+
1026
+ def cmd_flash_erase(self, label=None, full=False):
1027
+ """Erase flash/partition (filesystem reset)"""
1028
+ mode = "full" if full else "quick"
1029
+ if label:
1030
+ self.verbose(f"FLASH ERASE {label} ({mode})", 1)
1031
+ else:
1032
+ self.verbose(f"FLASH ERASE ({mode})", 1)
1033
+
1034
+ result = self._mpy.flash_erase(label=label, full=full, progress_callback=self._make_progress("erasing", label))
1035
+
1036
+ if self._verbose >= 1:
1037
+ print() # newline after progress
1038
+
1039
+ target = label or "flash"
1040
+ self.verbose(f" erased {self.format_size(result['erased'])} from {target}", 1, color='green')
1041
+ if not label:
1042
+ self.verbose(" filesystem will be recreated on next boot", 1, color='yellow')
1043
+
1044
+ def _cmd_flash_esp32(self):
1045
+ """List ESP32 partitions"""
1046
+ info = self._mpy.partitions()
1047
+ print(f"{'Label':<12} {'Type':<8} {'Subtype':<10} {'Address':>10} {'Size':>10} "
1048
+ f"{'Block':>8} {'Actual FS':<12} {'Flags'}")
1049
+ print("-" * 90)
1050
+
1051
+ for p in info['partitions']:
1052
+ flags = []
1053
+ if p['encrypted']:
1054
+ flags.append('enc')
1055
+ if p['running']:
1056
+ flags.append('running')
1057
+ # Block size column
1058
+ block_str = ''
1059
+ if p.get('fs_block_size'):
1060
+ block_str = self.format_size(p['fs_block_size'])
1061
+ # Filesystem column
1062
+ fs_info = ''
1063
+ if p.get('filesystem'):
1064
+ fs_info = p['filesystem']
1065
+ # For FAT, append cluster size
1066
+ if p.get('fs_cluster_size'):
1067
+ fs_info += f" ({self.format_size(p['fs_cluster_size'])})"
1068
+ print(f"{p['label']:<12} {p['type_name']:<8} {p['subtype_name']:<10} "
1069
+ f"{p['offset']:>#10x} {self.format_size(p['size']):>10} "
1070
+ f"{block_str:>8} {fs_info:<12} {', '.join(flags)}")
1071
+
1072
+ if info['boot']:
1073
+ print(f"\nBoot partition: {info['boot']}")
1074
+ if info['next_ota']:
1075
+ print(f"Next OTA: {info['next_ota']}")
1076
+
1077
+ def cmd_info(self):
1078
+ self.verbose("INFO", 2)
1079
+ plat = self._mpy.platform()
1080
+ print(f"Platform: {plat['platform']}")
1081
+ print(f"Version: {plat['version']}")
1082
+ print(f"Impl: {plat['impl']}")
1083
+ if plat['machine']:
1084
+ print(f"Machine: {plat['machine']}")
1085
+ uid = self._mpy.unique_id()
1086
+ if uid:
1087
+ print(f"Serial: {uid}")
1088
+ for iface, mac in self._mpy.mac_addresses():
1089
+ print(f"MAC {iface + ':':<8} {mac}")
1090
+ mem = self._mpy.memory()
1091
+ mem_pct = (mem['alloc'] / mem['total'] * 100) if mem['total'] > 0 else 0
1092
+ print(f"Memory: {self.format_size(mem['alloc'])} / {self.format_size(mem['total'])} ({mem_pct:.2f}%)")
1093
+ for fs in self._mpy.filesystems():
1094
+ label = "Flash:" if fs['mount'] == '/' else fs['mount'] + ':'
1095
+ fs_pct = (fs['used'] / fs['total'] * 100) if fs['total'] > 0 else 0
1096
+ print(f"{label:12} {self.format_size(fs['used'])} / {self.format_size(fs['total'])} ({fs_pct:.2f}%)")
1097
+
1098
+ def cmd_mreset(self, reconnect=True):
1099
+ """MCU reset using machine.reset() with optional auto-reconnect"""
1100
+ self.verbose("MRESET", 1)
1101
+ if reconnect:
1102
+ try:
1103
+ self.verbose(" reconnecting...", 1, color='yellow')
1104
+ self._mpy.machine_reset(reconnect=True)
1105
+ self.verbose(" connected", 1, color='green')
1106
+ except (_mpytool.ConnError, OSError) as err:
1107
+ self.verbose(f" reconnect failed: {err}", 1, color='red')
1108
+ raise _mpytool.ConnError(f"Reconnect failed: {err}")
1109
+ else:
1110
+ self._mpy.machine_reset(reconnect=False)
1111
+
1112
+ def cmd_sreset(self):
1113
+ """Soft reset in raw REPL - clears RAM but doesn't run boot.py/main.py"""
1114
+ self.verbose("SRESET", 1)
1115
+ self._mpy.soft_reset_raw()
1116
+
1117
+ def cmd_rtsreset(self, reconnect=True):
1118
+ """Hardware reset device using RTS signal with optional auto-reconnect"""
1119
+ self.verbose("RTSRESET", 1)
1120
+ try:
1121
+ self._mpy.hard_reset()
1122
+ if reconnect:
1123
+ self.verbose(" reconnecting...", 1, color='yellow')
1124
+ _time.sleep(1.0) # Wait for device to boot
1125
+ self._mpy._conn.reconnect()
1126
+ self.verbose(" connected", 1, color='green')
1127
+ except NotImplementedError:
1128
+ raise _mpytool.MpyError("Hardware reset not available (serial only)")
1129
+ except (_mpytool.ConnError, OSError) as err:
1130
+ self.verbose(f" reconnect failed: {err}", 1, color='red')
1131
+ raise _mpytool.ConnError(f"Reconnect failed: {err}")
1132
+
1133
+ def cmd_bootloader(self):
1134
+ """Enter bootloader using machine.bootloader()"""
1135
+ self.verbose("BOOTLOADER", 1)
1136
+ self._mpy.machine_bootloader()
1137
+
1138
+ def cmd_dtrboot(self):
1139
+ """Enter bootloader using DTR/RTS signals (ESP32 only)
1140
+
1141
+ Auto-detects port type:
1142
+ - USB-UART: classic DTR/RTS sequence (GPIO0 directly connected)
1143
+ - USB-CDC: USB-JTAG-Serial sequence (ESP32-S/C native USB)
1144
+ """
1145
+ self.verbose("DTRBOOT", 1)
1146
+ try:
1147
+ self._mpy.reset_to_bootloader()
1148
+ except NotImplementedError:
1149
+ raise _mpytool.MpyError("DTR boot not available (serial only)")
1150
+
1151
+ def cmd_sleep(self, seconds):
1152
+ """Sleep for specified number of seconds"""
1153
+ self.verbose(f"SLEEP {seconds}s", 1)
1154
+ _time.sleep(seconds)
1155
+
1156
+ def process_commands(self, commands, is_last_group=False):
197
1157
  try:
198
1158
  while commands:
199
1159
  command = commands.pop(0)
@@ -230,98 +1190,309 @@ class MpyTool():
230
1190
  elif command == 'mkdir':
231
1191
  self.cmd_mkdir(*commands)
232
1192
  break
233
- elif command in ('del', 'delete'):
1193
+ elif command in ('del', 'delete', 'rm'):
234
1194
  self.cmd_delete(*commands)
235
1195
  break
236
1196
  elif command == 'reset':
237
- self._mpy.comm.soft_reset()
238
- elif command == 'follow':
239
- self.cmd_follow()
1197
+ self.verbose("RESET", 1)
1198
+ self._mpy.soft_reset()
1199
+ elif command == 'mreset':
1200
+ # Reconnect only if there are more commands (in this or next group)
1201
+ has_more = bool(commands) or not is_last_group
1202
+ self.cmd_mreset(reconnect=has_more)
1203
+ elif command == 'sreset':
1204
+ self.cmd_sreset()
1205
+ elif command in ('monitor', 'follow'):
1206
+ self.cmd_monitor()
240
1207
  break
241
1208
  elif command == 'repl':
242
1209
  self.cmd_repl()
243
1210
  break
1211
+ elif command == 'exec':
1212
+ if commands:
1213
+ code = commands.pop(0)
1214
+ self.cmd_exec(code)
1215
+ else:
1216
+ raise ParamsError('missing code for exec command')
1217
+ elif command == 'info':
1218
+ self.cmd_info()
1219
+ elif command == 'flash':
1220
+ if commands and commands[0] == 'read':
1221
+ commands.pop(0)
1222
+ if len(commands) >= 2:
1223
+ # ESP32: flash read <label> <file>
1224
+ label = commands.pop(0)
1225
+ dest_path = commands.pop(0)
1226
+ self.cmd_flash_read(dest_path, label=label)
1227
+ elif len(commands) == 1:
1228
+ # RP2: flash read <file>
1229
+ dest_path = commands.pop(0)
1230
+ self.cmd_flash_read(dest_path)
1231
+ else:
1232
+ raise ParamsError('flash read requires destination file')
1233
+ elif commands and commands[0] == 'write':
1234
+ commands.pop(0)
1235
+ if len(commands) >= 2:
1236
+ # ESP32: flash write <label> <file>
1237
+ label = commands.pop(0)
1238
+ src_path = commands.pop(0)
1239
+ self.cmd_flash_write(src_path, label=label)
1240
+ elif len(commands) == 1:
1241
+ # RP2: flash write <file>
1242
+ src_path = commands.pop(0)
1243
+ self.cmd_flash_write(src_path)
1244
+ else:
1245
+ raise ParamsError('flash write requires source file')
1246
+ elif commands and commands[0] == 'erase':
1247
+ commands.pop(0)
1248
+ # Check for --full flag and optional label
1249
+ full = False
1250
+ label = None
1251
+ while commands and (commands[0] == '--full' or not commands[0].startswith('-')):
1252
+ if commands[0] == '--full':
1253
+ full = True
1254
+ commands.pop(0)
1255
+ elif label is None:
1256
+ label = commands.pop(0)
1257
+ else:
1258
+ break
1259
+ self.cmd_flash_erase(label=label, full=full)
1260
+ else:
1261
+ self.cmd_flash()
1262
+ elif command == 'ota':
1263
+ if commands:
1264
+ firmware_path = commands.pop(0)
1265
+ self.cmd_ota(firmware_path)
1266
+ else:
1267
+ raise ParamsError('ota requires firmware file path')
1268
+ elif command == 'rtsreset':
1269
+ # Reconnect only if there are more commands (in this or next group)
1270
+ has_more = bool(commands) or not is_last_group
1271
+ self.cmd_rtsreset(reconnect=has_more)
1272
+ elif command == 'bootloader':
1273
+ self.cmd_bootloader()
1274
+ elif command == 'dtrboot':
1275
+ self.cmd_dtrboot()
1276
+ elif command == 'sleep':
1277
+ if commands:
1278
+ try:
1279
+ seconds = float(commands.pop(0))
1280
+ except ValueError:
1281
+ raise ParamsError('sleep requires a number (seconds)')
1282
+ self.cmd_sleep(seconds)
1283
+ else:
1284
+ raise ParamsError('sleep requires a number (seconds)')
1285
+ elif command == 'cp':
1286
+ if len(commands) >= 2:
1287
+ self.cmd_cp(*commands)
1288
+ break
1289
+ raise ParamsError('cp requires source and destination')
1290
+ elif command == 'mv':
1291
+ if len(commands) >= 2:
1292
+ self.cmd_mv(*commands)
1293
+ break
1294
+ raise ParamsError('mv requires source and destination')
1295
+ elif command == '_paths':
1296
+ # Undocumented: for shell completion
1297
+ if commands:
1298
+ self.cmd_paths(commands.pop(0))
1299
+ else:
1300
+ self.cmd_paths()
244
1301
  else:
245
1302
  raise ParamsError(f"unknown command: '{command}'")
246
- except _mpytool.MpyError as err:
247
- if self._log:
248
- self._log.error(err)
249
- else:
250
- print(err)
251
- self._mpy.comm.exit_raw_repl()
252
-
253
-
254
- class SimpleColorLogger():
255
- def __init__(self, loglevel=0):
256
- self._loglevel = loglevel
257
-
258
- def log(self, msg):
259
- print(msg, file=_sys.stderr)
260
-
261
- def error(self, msg):
262
- if self._loglevel >= 1:
263
- self.log(f"\033[1;31m{msg}\033[0m")
264
-
265
- def warning(self, msg):
266
- if self._loglevel >= 2:
267
- self.log(f"\033[1;33m{msg}\033[0m")
268
-
269
- def info(self, msg):
270
- if self._loglevel >= 3:
271
- self.log(f"\033[1;35m{msg}\033[0m")
272
-
273
- def debug(self, msg):
274
- if self._loglevel >= 4:
275
- self.log(f"\033[1;34m{msg}\033[0m")
1303
+ except (_mpytool.MpyError, _mpytool.ConnError) as err:
1304
+ self._log.error(err)
1305
+ try:
1306
+ self._mpy.comm.exit_raw_repl()
1307
+ except _mpytool.ConnError:
1308
+ pass # connection already lost
276
1309
 
277
1310
 
278
- _VERSION_STR = "%s %s (%s <%s>)" % (
279
- _about.APP_NAME,
280
- _about.VERSION,
281
- _about.AUTHOR,
282
- _about.AUTHOR_EMAIL)
1311
+ if _about:
1312
+ _VERSION_STR = "%s %s (%s)" % (_about["Name"], _about["Version"], _about["Author-email"])
1313
+ else:
1314
+ _VERSION_STR = "mpytool (not installed version)"
283
1315
  _COMMANDS_HELP_STR = """
284
1316
  List of available commands:
285
1317
  ls [{path}] list files and its sizes
286
1318
  tree [{path}] list tree of structure and sizes
1319
+ cp [-f] {src} [...] {dst} copy files (: prefix = device path, -f = force)
1320
+ mv {src} [...] {dst} move/rename on device (: prefix required)
287
1321
  get {path} [...] get file and print it
288
1322
  put {src_path} [{dst_path}] put file or directory to destination
289
1323
  mkdir {path} [...] create directory (also create all parents)
290
- delete {path} [...] remove file or directory (recursively)
291
- reset soft reset
292
- follow print log of running program
1324
+ delete {path} [...] remove file/dir (path/ = contents only)
1325
+ reset soft reset (Ctrl-D, runs boot.py/main.py)
1326
+ sreset soft reset in raw REPL (clears RAM only)
1327
+ mreset MCU reset (machine.reset, auto-reconnect)
1328
+ rtsreset RTS reset (hardware reset via RTS signal)
1329
+ bootloader enter bootloader (machine.bootloader)
1330
+ dtrboot enter bootloader via DTR/RTS (ESP32 only)
1331
+ monitor print output of running program
293
1332
  repl enter REPL mode [UNIX OS ONLY]
1333
+ exec {code} execute Python code on device
1334
+ info show device information
1335
+ flash show flash info (RP2) or partitions (ESP32)
1336
+ flash read [{label}] {file} read flash/partition to file
1337
+ flash write [{label}] {file} write file to flash/partition
1338
+ flash erase [{label}] [--full] erase flash/partition
1339
+ ota {firmware.app-bin} OTA firmware update (ESP32 only)
1340
+ sleep {seconds} sleep for specified seconds
294
1341
  Aliases:
295
1342
  dir alias to ls
296
1343
  cat alias to get
297
- del alias to delete
1344
+ del, rm alias to delete
1345
+ follow alias to monitor
1346
+ Use -- to separate multiple commands:
1347
+ mpytool put main.py / -- reset -- monitor
298
1348
  """
299
1349
 
300
1350
 
1351
+ def _run_commands(mpy_tool, command_groups, with_progress=True):
1352
+ """Execute command groups with optional batch progress tracking"""
1353
+ if not with_progress:
1354
+ for i, commands in enumerate(command_groups):
1355
+ is_last = (i == len(command_groups) - 1)
1356
+ mpy_tool.process_commands(commands, is_last_group=is_last)
1357
+ return
1358
+ i = 0
1359
+ while i < len(command_groups):
1360
+ is_copy, count, src_paths, dst_paths = mpy_tool.count_files_for_command(command_groups[i])
1361
+ if not is_copy:
1362
+ is_last = (i == len(command_groups) - 1)
1363
+ mpy_tool.process_commands(command_groups[i], is_last_group=is_last)
1364
+ i += 1
1365
+ continue
1366
+ batch_total = count
1367
+ all_src_paths = src_paths
1368
+ all_dst_paths = dst_paths
1369
+ batch_start = i
1370
+ j = i + 1
1371
+ while j < len(command_groups):
1372
+ is_copy_j, count_j, src_paths_j, dst_paths_j = mpy_tool.count_files_for_command(command_groups[j])
1373
+ if not is_copy_j:
1374
+ break
1375
+ batch_total += count_j
1376
+ all_src_paths.extend(src_paths_j)
1377
+ all_dst_paths.extend(dst_paths_j)
1378
+ j += 1
1379
+ max_src_len = max(len(p) for p in all_src_paths) if all_src_paths else 0
1380
+ max_dst_len = max(len(p) for p in all_dst_paths) if all_dst_paths else 0
1381
+ mpy_tool.print_transfer_info()
1382
+ mpy_tool.set_batch_progress(batch_total, max_src_len, max_dst_len)
1383
+ for k in range(batch_start, j):
1384
+ is_last = (k == j - 1) and (j == len(command_groups))
1385
+ mpy_tool.process_commands(command_groups[k], is_last_group=is_last)
1386
+ mpy_tool.print_copy_summary()
1387
+ mpy_tool.reset_batch_progress()
1388
+ i = j
1389
+
1390
+
1391
+ def _parse_chunk_size(value):
1392
+ """Parse chunk size value (e.g., '1K', '2K', '4096')"""
1393
+ valid = {512, 1024, 2048, 4096, 8192, 16384, 32768}
1394
+ val = value.upper()
1395
+ if val.endswith('K'):
1396
+ try:
1397
+ num = int(val[:-1]) * 1024
1398
+ except ValueError:
1399
+ raise _argparse.ArgumentTypeError(f"invalid chunk size: {value}")
1400
+ else:
1401
+ try:
1402
+ num = int(value)
1403
+ except ValueError:
1404
+ raise _argparse.ArgumentTypeError(f"invalid chunk size: {value}")
1405
+ if num not in valid:
1406
+ valid_str = ', '.join(f'{v//1024}K' if v >= 1024 else str(v) for v in sorted(valid))
1407
+ raise _argparse.ArgumentTypeError(f"chunk size must be one of: {valid_str}")
1408
+ return num
1409
+
1410
+
301
1411
  def main():
302
1412
  """Main"""
1413
+ _description = _about["Summary"] if _about else None
303
1414
  parser = _argparse.ArgumentParser(
1415
+ description=_description,
304
1416
  formatter_class=_argparse.RawTextHelpFormatter,
305
1417
  epilog=_COMMANDS_HELP_STR)
306
1418
  parser.add_argument(
307
1419
  "-V", "--version", action='version', version=_VERSION_STR)
308
- parser.add_argument('-p', '--port', required=True, help="serial port")
1420
+ parser.add_argument('-p', '--port', help="serial port")
1421
+ parser.add_argument('-a', '--address', help="network address")
1422
+ parser.add_argument('-b', '--baud', type=int, default=115200, help="serial port")
309
1423
  parser.add_argument(
310
1424
  '-d', '--debug', default=0, action='count', help='set debug level')
311
1425
  parser.add_argument(
312
- '-v', '--verbose', default=0, action='count', help='verbose output')
1426
+ '-v', '--verbose', action='store_true', help='verbose output (show commands)')
1427
+ parser.add_argument(
1428
+ '-q', '--quiet', action='store_true', help='quiet mode (no progress)')
1429
+ parser.add_argument(
1430
+ "-e", "--exclude", type=str, action='append', dest='exclude',
1431
+ help='exclude files/dirs (wildcards: *, ?), default: *.pyc, .*')
1432
+ parser.add_argument(
1433
+ '-f', '--force', action='store_true', help='force overwrite (skip unchanged check)')
313
1434
  parser.add_argument(
314
- "-e", "--exclude-dir", type=str, action='append', help='exclude dir, '
315
- 'by default are excluded directories: __pycache__, .git, .svn')
316
- parser.add_argument('commands', nargs='*', help='commands')
1435
+ '-z', '--compress', action='store_true', default=None, help='force compression')
1436
+ parser.add_argument(
1437
+ '-Z', '--no-compress', action='store_true', help='disable compression')
1438
+ parser.add_argument(
1439
+ '-c', '--chunk-size', type=_parse_chunk_size, metavar='SIZE',
1440
+ help='transfer chunk size: 512, 1K, 2K, 4K, 8K, 16K, 32K (default: auto)')
1441
+ parser.add_argument('commands', nargs=_argparse.REMAINDER, help='commands')
317
1442
  args = parser.parse_args()
1443
+ # Convert to numeric level: 0=quiet, 1=progress, 2=verbose
1444
+ if args.quiet:
1445
+ args.verbose = 0
1446
+ elif args.verbose:
1447
+ args.verbose = 2
1448
+ else:
1449
+ args.verbose = 1
318
1450
 
319
- log = SimpleColorLogger(args.debug + 1)
320
- conn = _mpytool.ConnSerial(
321
- port=args.port, baudrate=115200, log=log)
1451
+ log = SimpleColorLogger(args.debug + 1, verbose_level=args.verbose)
1452
+ if args.port and args.address:
1453
+ log.error("You can select only serial port or network address")
1454
+ return
1455
+ port = args.port
1456
+ if not port and not args.address:
1457
+ ports = _utils.detect_serial_ports()
1458
+ if not ports:
1459
+ log.error("No serial port found. Use -p to specify port.")
1460
+ return
1461
+ if len(ports) == 1:
1462
+ port = ports[0]
1463
+ log.verbose(f"Using {port}", level=2)
1464
+ else:
1465
+ log.error("Multiple serial ports found. Use -p to specify one:")
1466
+ for p in ports:
1467
+ print(f" {p}")
1468
+ return
1469
+ try:
1470
+ if port:
1471
+ conn = _mpytool.ConnSerial(
1472
+ port=port, baudrate=args.baud, log=log)
1473
+ elif args.address:
1474
+ conn = _mpytool.ConnSocket(
1475
+ address=args.address, log=log)
1476
+ except _mpytool.ConnError as err:
1477
+ log.error(err)
1478
+ return
1479
+ # Determine compression setting: None=auto, True=force, False=disable
1480
+ compress = None
1481
+ if args.no_compress:
1482
+ compress = False
1483
+ elif args.compress:
1484
+ compress = True
322
1485
  mpy_tool = MpyTool(
323
- conn, log=log, verbose=args.verbose, exclude_dirs=args.exclude_dir)
324
- mpy_tool.process_commands(args.commands)
1486
+ conn, log=log, verbose=log, exclude_dirs=args.exclude,
1487
+ force=args.force, compress=compress, chunk_size=args.chunk_size)
1488
+ command_groups = _utils.split_commands(args.commands)
1489
+ try:
1490
+ _run_commands(mpy_tool, command_groups, with_progress=(args.verbose >= 1))
1491
+ except KeyboardInterrupt:
1492
+ # Clear partial progress line and show clean message
1493
+ log.verbose('Interrupted', level=0, overwrite=True)
1494
+ except (_mpytool.MpyError, _mpytool.ConnError, _mpytool.Timeout) as err:
1495
+ log.error(err)
325
1496
 
326
1497
 
327
1498
  if __name__ == '__main__':