mpytool 1.1.0__py3-none-any.whl → 2.0.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
@@ -3,34 +3,21 @@
3
3
  import os as _os
4
4
  import sys as _sys
5
5
  import argparse as _argparse
6
+ import hashlib as _hashlib
6
7
  import mpytool as _mpytool
7
- import mpytool.__about__ as _about
8
+ import mpytool.terminal as _terminal
9
+ import mpytool.utils as _utils
10
+ from mpytool.logger import SimpleColorLogger
11
+ import importlib.metadata as _metadata
8
12
 
9
-
10
- class ParamsError(_mpytool.MpyError):
11
- """Timeout"""
12
-
13
-
14
- class PathNotFound(_mpytool.MpyError):
15
- """File not found"""
16
- def __init__(self, file_name):
17
- self._file_name = file_name
18
- super().__init__(self.__str__())
19
-
20
- def __str__(self):
21
- return f"Path '{self._file_name}' was not found"
13
+ try:
14
+ _about = _metadata.metadata("mpytool")
15
+ except _metadata.PackageNotFoundError:
16
+ _about = None
22
17
 
23
18
 
24
- class FileNotFound(PathNotFound):
25
- """Folder not found"""
26
- def __str__(self):
27
- return f"File '{self._file_name}' was not found"
28
-
29
-
30
- class DirNotFound(PathNotFound):
31
- """Folder not found"""
32
- def __str__(self):
33
- return f"Dir '{self._file_name}' was not found"
19
+ class ParamsError(_mpytool.MpyError):
20
+ """Invalid command parameters"""
34
21
 
35
22
 
36
23
  class MpyTool():
@@ -39,18 +26,230 @@ class MpyTool():
39
26
  TEE = '├─ '
40
27
  LAST = '└─ '
41
28
 
42
- def __init__(self, conn, log=None, verbose=0, exclude_dirs=None):
29
+ def __init__(self, conn, log=None, verbose=None, exclude_dirs=None, force=False):
43
30
  self._conn = conn
44
- self._log = log
45
- self._verbose = verbose
31
+ self._log = log if log is not None else SimpleColorLogger()
32
+ self._verbose_out = verbose # None = no verbose output (API mode)
46
33
  self._exclude_dirs = {'__pycache__', '.git', '.svn'}
47
34
  if exclude_dirs:
48
35
  self._exclude_dirs.update(exclude_dirs)
49
- self._mpy = _mpytool.Mpy(conn, log=log)
36
+ self._mpy = _mpytool.Mpy(conn, log=self._log)
37
+ self._force = force # Skip unchanged file check
38
+ # Progress tracking
39
+ self._progress_total_files = 0
40
+ self._progress_current_file = 0
41
+ self._progress_src = ''
42
+ self._progress_dst = ''
43
+ self._progress_max_src_len = 0
44
+ self._is_debug = getattr(self._log, '_loglevel', 1) >= 4
45
+ self._batch_mode = False
46
+ self._skipped_files = 0
47
+
48
+ @property
49
+ def _is_tty(self):
50
+ if self._verbose_out is None:
51
+ return False
52
+ return self._verbose_out._is_tty
53
+
54
+ @property
55
+ def _verbose(self):
56
+ if self._verbose_out is None:
57
+ return 0
58
+ return self._verbose_out._verbose_level
59
+
60
+ def verbose(self, msg, level=1, color='green', end='\n', overwrite=False):
61
+ if self._verbose_out is not None:
62
+ self._verbose_out.verbose(msg, level, color, end, overwrite)
63
+
64
+ @staticmethod
65
+ def _format_local_path(path):
66
+ """Format local path: relative from CWD, absolute if > 2 levels up"""
67
+ try:
68
+ rel_path = _os.path.relpath(path)
69
+ # Count leading ../
70
+ parts = rel_path.split(_os.sep)
71
+ up_count = 0
72
+ for part in parts:
73
+ if part == '..':
74
+ up_count += 1
75
+ else:
76
+ break
77
+ if up_count > 2:
78
+ return _os.path.abspath(path)
79
+ return rel_path
80
+ except ValueError:
81
+ # On Windows, relpath fails for different drives
82
+ return _os.path.abspath(path)
83
+
84
+ def _format_progress_line(self, percent, total):
85
+ """Format progress line: [2/5] 23% 24.1KB source -> dest"""
86
+ size_str = self.format_size(total).replace(' ', '')
87
+ if self._progress_total_files > 1:
88
+ prefix = f"[{self._progress_current_file}/{self._progress_total_files}]"
89
+ else:
90
+ prefix = ""
91
+ # Pad source to align ->
92
+ src_width = max(len(self._progress_src), self._progress_max_src_len)
93
+ return f"{prefix:>7} {percent:3d}% {size_str:>7} {self._progress_src:<{src_width}} -> {self._progress_dst}"
94
+
95
+ def _progress_callback(self, transferred, total):
96
+ """Callback for file transfer progress"""
97
+ percent = (transferred * 100 // total) if total > 0 else 100
98
+ line = self._format_progress_line(percent, total)
99
+ if self._is_debug:
100
+ # Debug mode: always newlines
101
+ self.verbose(line, color='cyan')
102
+ else:
103
+ # Normal mode: overwrite line
104
+ self.verbose(line, color='cyan', end='', overwrite=True)
105
+
106
+ def _progress_complete(self, total):
107
+ """Mark current file as complete"""
108
+ line = self._format_progress_line(100, total)
109
+ if self._is_debug:
110
+ # Already printed with newline in callback
111
+ pass
112
+ else:
113
+ # Print final line with newline
114
+ self.verbose(line, color='cyan', overwrite=True)
50
115
 
51
- def verbose(self, msg, level=1):
52
- if self._verbose >= level:
53
- print(msg, file=_sys.stderr)
116
+ def _set_progress_info(self, src, dst, is_src_remote, is_dst_remote):
117
+ """Set progress source and destination paths"""
118
+ if is_src_remote:
119
+ self._progress_src = ':' + src
120
+ else:
121
+ self._progress_src = self._format_local_path(src)
122
+ if is_dst_remote:
123
+ self._progress_dst = ':' + dst
124
+ else:
125
+ self._progress_dst = self._format_local_path(dst)
126
+
127
+ def _count_local_files(self, path):
128
+ """Count files in local directory (excluding excluded dirs)"""
129
+ if _os.path.isfile(path):
130
+ return 1
131
+ count = 0
132
+ for _, dirs, files in _os.walk(path, topdown=True):
133
+ dirs[:] = [d for d in dirs if d not in self._exclude_dirs]
134
+ count += len(files)
135
+ return count
136
+
137
+ def _file_needs_update(self, local_data, remote_path):
138
+ """Check if local file differs from remote file
139
+
140
+ Returns True if file needs to be uploaded (different or doesn't exist)
141
+ """
142
+ if self._force:
143
+ return True
144
+ # Check remote file size first (fast)
145
+ remote_size = self._mpy.stat(remote_path)
146
+ if remote_size is None or remote_size < 0:
147
+ return True # File doesn't exist or is a directory
148
+ local_size = len(local_data)
149
+ if local_size != remote_size:
150
+ return True # Different size
151
+ # Sizes match - check hash
152
+ local_hash = _hashlib.sha256(local_data).digest()
153
+ remote_hash = self._mpy.hashfile(remote_path)
154
+ if remote_hash is None:
155
+ return True # hashlib not available on device
156
+ return local_hash != remote_hash
157
+
158
+ def _count_remote_files(self, path):
159
+ """Count files on device"""
160
+ stat = self._mpy.stat(path)
161
+ if stat is None:
162
+ return 0
163
+ if stat >= 0: # file
164
+ return 1
165
+ # directory - count recursively
166
+ count = 0
167
+ entries = self._mpy.ls(path)
168
+ for name, size in entries:
169
+ if size is None: # directory
170
+ entry_path = path.rstrip('/') + '/' + name
171
+ count += self._count_remote_files(entry_path)
172
+ else: # file
173
+ count += 1
174
+ return count
175
+
176
+ def _collect_source_paths(self, commands):
177
+ """Collect source paths for a cp/put command (for alignment calculation)"""
178
+ paths = []
179
+ if not commands:
180
+ return paths
181
+ cmd = commands[0]
182
+ if cmd == 'cp' and len(commands) >= 3:
183
+ sources = commands[1:-1]
184
+ for src in sources:
185
+ src_is_remote = src.startswith(':')
186
+ src_path = src[1:] if src_is_remote else src
187
+ if not src_path:
188
+ src_path = '/'
189
+ src_path = src_path.rstrip('/') or '/'
190
+ if src_is_remote:
191
+ # Collect all file paths from remote
192
+ paths.extend(self._collect_remote_paths(src_path))
193
+ else:
194
+ # Collect all file paths from local
195
+ paths.extend(self._collect_local_paths(src_path))
196
+ elif cmd == 'put' and len(commands) >= 2:
197
+ src_path = commands[1]
198
+ paths.extend(self._collect_local_paths(src_path))
199
+ return paths
200
+
201
+ def _collect_local_paths(self, path):
202
+ """Collect all local file paths (formatted for display)"""
203
+ paths = []
204
+ if _os.path.isfile(path):
205
+ paths.append(self._format_local_path(path))
206
+ elif _os.path.isdir(path):
207
+ for root, dirs, files in _os.walk(path, topdown=True):
208
+ dirs[:] = [d for d in dirs if d not in self._exclude_dirs]
209
+ for f in files:
210
+ full_path = _os.path.join(root, f)
211
+ paths.append(self._format_local_path(full_path))
212
+ return paths
213
+
214
+ def _collect_remote_paths(self, path):
215
+ """Collect all remote file paths (formatted for display)"""
216
+ paths = []
217
+ stat = self._mpy.stat(path)
218
+ if stat is None:
219
+ return paths
220
+ if stat >= 0: # file
221
+ paths.append(':' + path)
222
+ else: # directory
223
+ entries = self._mpy.ls(path)
224
+ for name, size in entries:
225
+ entry_path = path.rstrip('/') + '/' + name
226
+ if size is None: # directory
227
+ paths.extend(self._collect_remote_paths(entry_path))
228
+ else: # file
229
+ paths.append(':' + entry_path)
230
+ return paths
231
+
232
+ def count_files_for_command(self, commands):
233
+ """Count files that would be transferred by cp/put command.
234
+ Returns (is_copy_command, file_count, source_paths)"""
235
+ paths = self._collect_source_paths(commands)
236
+ if paths:
237
+ return True, len(paths), paths
238
+ return False, 0, []
239
+
240
+ def set_batch_progress(self, total_files, max_src_len=0):
241
+ """Set batch progress for consecutive copy commands"""
242
+ self._progress_total_files = total_files
243
+ self._progress_current_file = 0
244
+ self._progress_max_src_len = max_src_len
245
+ self._batch_mode = True
246
+
247
+ def reset_batch_progress(self):
248
+ """Reset batch progress mode"""
249
+ self._batch_mode = False
250
+ self._progress_total_files = 0
251
+ self._progress_current_file = 0
252
+ self._progress_max_src_len = 0
54
253
 
55
254
  def cmd_ls(self, dir_name):
56
255
  result = self._mpy.ls(dir_name)
@@ -107,16 +306,17 @@ class MpyTool():
107
306
 
108
307
  def cmd_get(self, *file_names):
109
308
  for file_name in file_names:
110
- self.verbose(f"GET: {file_name}")
309
+ self.verbose(f"GET: {file_name}", 2)
111
310
  data = self._mpy.get(file_name)
112
311
  print(data.decode('utf-8'))
113
312
 
114
- def _put_dir(self, src_path, dst_path):
313
+ def _put_dir(self, src_path, dst_path, show_progress=True):
115
314
  basename = _os.path.basename(src_path)
116
315
  if basename:
117
316
  dst_path = _os.path.join(dst_path, basename)
118
- self.verbose(f"PUT_DIR: {src_path} -> {dst_path}")
119
- for path, _dirs, files in _os.walk(src_path):
317
+ self.verbose(f"PUT DIR: {src_path} -> {dst_path}", 2)
318
+ for path, dirs, files in _os.walk(src_path, topdown=True):
319
+ dirs[:] = [d for d in dirs if d not in self._exclude_dirs]
120
320
  basename = _os.path.basename(path)
121
321
  if basename in self._exclude_dirs:
122
322
  continue
@@ -125,33 +325,67 @@ class MpyTool():
125
325
  rel_path = ''
126
326
  rel_path = _os.path.join(dst_path, rel_path)
127
327
  if rel_path:
128
- self.verbose(f'mkdir: {rel_path}', 2)
328
+ self.verbose(f'MKDIR: {rel_path}', 2)
129
329
  self._mpy.mkdir(rel_path)
130
330
  for file_name in files:
131
331
  spath = _os.path.join(path, file_name)
132
332
  dpath = _os.path.join(rel_path, file_name)
133
- self.verbose(f" {dpath}")
134
333
  with open(spath, 'rb') as src_file:
135
334
  data = src_file.read()
335
+ # Check if file needs update
336
+ if not self._file_needs_update(data, dpath):
337
+ self._skipped_files += 1
338
+ if show_progress and self._verbose >= 1:
339
+ self._progress_current_file += 1
340
+ self._set_progress_info(spath, dpath, False, True)
341
+ self.verbose(f" skip {self._progress_src} (unchanged)", color='yellow')
342
+ continue
343
+ if show_progress and self._verbose >= 1:
344
+ self._progress_current_file += 1
345
+ self._set_progress_info(spath, dpath, False, True)
346
+ self._mpy.put(data, dpath, self._progress_callback)
347
+ self._progress_complete(len(data))
348
+ else:
136
349
  self._mpy.put(data, dpath)
137
350
 
138
- def _put_file(self, src_path, dst_path):
351
+ def _put_file(self, src_path, dst_path, show_progress=True):
139
352
  basename = _os.path.basename(src_path)
140
353
  if basename and not _os.path.basename(dst_path):
141
354
  dst_path = _os.path.join(dst_path, basename)
142
- self.verbose(f"PUT_FILE: {src_path} -> {dst_path}")
143
- path = _os.path.dirname(dst_path)
144
- result = self._mpy.stat(path)
145
- if result is None:
146
- self._mpy.mkdir(path)
147
- elif result >= 0:
148
- raise _mpytool.MpyError(
149
- f'Error creating file under file: {path}')
355
+ self.verbose(f"PUT FILE: {src_path} -> {dst_path}", 2)
356
+ # Read local file
150
357
  with open(src_path, 'rb') as src_file:
151
358
  data = src_file.read()
359
+ # Check if file needs update
360
+ if not self._file_needs_update(data, dst_path):
361
+ self._skipped_files += 1
362
+ if show_progress and self._verbose >= 1:
363
+ self._progress_current_file += 1
364
+ self._set_progress_info(src_path, dst_path, False, True)
365
+ self.verbose(f" skip {self._progress_src} (unchanged)", color='yellow')
366
+ return
367
+ # Create parent directory if needed
368
+ path = _os.path.dirname(dst_path)
369
+ if path:
370
+ result = self._mpy.stat(path)
371
+ if result is None:
372
+ self._mpy.mkdir(path)
373
+ elif result >= 0:
374
+ raise _mpytool.MpyError(
375
+ f'Error creating file under file: {path}')
376
+ # Upload file
377
+ if show_progress and self._verbose >= 1:
378
+ self._progress_current_file += 1
379
+ self._set_progress_info(src_path, dst_path, False, True)
380
+ self._mpy.put(data, dst_path, self._progress_callback)
381
+ self._progress_complete(len(data))
382
+ else:
152
383
  self._mpy.put(data, dst_path)
153
384
 
154
385
  def cmd_put(self, src_path, dst_path):
386
+ if self._verbose >= 1 and not self._batch_mode:
387
+ self._progress_total_files = self._count_local_files(src_path)
388
+ self._progress_current_file = 0
155
389
  if _os.path.isdir(src_path):
156
390
  self._put_dir(src_path, dst_path)
157
391
  elif _os.path.isfile(src_path):
@@ -159,27 +393,311 @@ class MpyTool():
159
393
  else:
160
394
  raise ParamsError(f'No file or directory to upload: {src_path}')
161
395
 
396
+ def _get_file(self, src_path, dst_path, show_progress=True):
397
+ """Download single file from device"""
398
+ self.verbose(f"GET FILE: {src_path} -> {dst_path}", 2)
399
+ # Create destination directory if needed
400
+ dst_dir = _os.path.dirname(dst_path)
401
+ if dst_dir and not _os.path.exists(dst_dir):
402
+ _os.makedirs(dst_dir)
403
+ if show_progress and self._verbose >= 1:
404
+ self._progress_current_file += 1
405
+ self._set_progress_info(src_path, dst_path, True, False)
406
+ data = self._mpy.get(src_path, self._progress_callback)
407
+ self._progress_complete(len(data))
408
+ else:
409
+ data = self._mpy.get(src_path)
410
+ with open(dst_path, 'wb') as dst_file:
411
+ dst_file.write(data)
412
+
413
+ def _get_dir(self, src_path, dst_path, copy_contents=False, show_progress=True):
414
+ """Download directory from device"""
415
+ if not copy_contents:
416
+ basename = src_path.rstrip('/').split('/')[-1]
417
+ if basename:
418
+ dst_path = _os.path.join(dst_path, basename)
419
+ self.verbose(f"GET DIR: {src_path} -> {dst_path}", 2)
420
+ if not _os.path.exists(dst_path):
421
+ _os.makedirs(dst_path)
422
+ entries = self._mpy.ls(src_path)
423
+ for name, size in entries:
424
+ src_entry = src_path.rstrip('/') + '/' + name
425
+ dst_entry = _os.path.join(dst_path, name)
426
+ if size is None: # directory
427
+ self._get_dir(src_entry, dst_entry, copy_contents=True, show_progress=show_progress)
428
+ else: # file
429
+ self._get_file(src_entry, dst_entry, show_progress=show_progress)
430
+
431
+ def _cp_local_to_remote(self, src_path, dst_path, dst_is_dir):
432
+ """Upload local file/dir to device"""
433
+ src_is_dir = _os.path.isdir(src_path)
434
+ copy_contents = src_path.endswith('/')
435
+ src_path = src_path.rstrip('/')
436
+ if not _os.path.exists(src_path):
437
+ raise ParamsError(f'Source not found: {src_path}')
438
+ if dst_is_dir:
439
+ if not copy_contents:
440
+ basename = _os.path.basename(src_path)
441
+ dst_path = dst_path + basename
442
+ dst_path = dst_path.rstrip('/')
443
+ if src_is_dir:
444
+ if copy_contents:
445
+ # Copy contents of directory
446
+ for item in _os.listdir(src_path):
447
+ item_src = _os.path.join(src_path, item)
448
+ if _os.path.isdir(item_src):
449
+ self._put_dir(item_src, dst_path)
450
+ else:
451
+ self._put_file(item_src, dst_path + '/')
452
+ else:
453
+ self._put_dir(src_path, _os.path.dirname(dst_path) or '/')
454
+ else:
455
+ self._put_file(src_path, dst_path)
456
+
457
+ def _cp_remote_to_local(self, src_path, dst_path, dst_is_dir):
458
+ """Download file/dir from device to local"""
459
+ copy_contents = src_path.endswith('/')
460
+ src_path = src_path.rstrip('/') or '/'
461
+ stat = self._mpy.stat(src_path)
462
+ if stat is None:
463
+ raise ParamsError(f'Source not found on device: {src_path}')
464
+ src_is_dir = (stat == -1)
465
+ if dst_is_dir:
466
+ if not _os.path.exists(dst_path):
467
+ _os.makedirs(dst_path)
468
+ if not copy_contents and src_path != '/':
469
+ basename = src_path.split('/')[-1]
470
+ dst_path = _os.path.join(dst_path, basename)
471
+ if src_is_dir:
472
+ self._get_dir(src_path, dst_path, copy_contents=copy_contents)
473
+ else:
474
+ if _os.path.isdir(dst_path):
475
+ basename = src_path.split('/')[-1]
476
+ dst_path = _os.path.join(dst_path, basename)
477
+ self._get_file(src_path, dst_path)
478
+
479
+ def _cp_remote_to_remote(self, src_path, dst_path, dst_is_dir):
480
+ """Copy file on device"""
481
+ src_path = src_path.rstrip('/') or '/'
482
+ stat = self._mpy.stat(src_path)
483
+ if stat is None:
484
+ raise ParamsError(f'Source not found on device: {src_path}')
485
+ if stat == -1:
486
+ raise ParamsError('Remote-to-remote directory copy not supported yet')
487
+ # File copy on device
488
+ if dst_is_dir:
489
+ basename = src_path.split('/')[-1]
490
+ dst_path = dst_path + basename
491
+ self.verbose(f"COPY: {src_path} -> {dst_path}", 2)
492
+ if self._verbose >= 1:
493
+ self._progress_current_file += 1
494
+ self._set_progress_info(src_path, dst_path, True, True)
495
+ data = self._mpy.get(src_path, self._progress_callback)
496
+ self._mpy.put(data, dst_path)
497
+ self._progress_complete(len(data))
498
+ else:
499
+ data = self._mpy.get(src_path)
500
+ self._mpy.put(data, dst_path)
501
+
502
+ def cmd_cp(self, *args):
503
+ """Copy files between local and device"""
504
+ if len(args) < 2:
505
+ raise ParamsError('cp requires source and destination')
506
+ sources = list(args[:-1])
507
+ dest = args[-1]
508
+ dest_is_remote = dest.startswith(':')
509
+ dest_path = dest[1:] if dest_is_remote else dest
510
+ if not dest_path:
511
+ dest_path = '/'
512
+ dest_is_dir = dest_path.endswith('/')
513
+ if len(sources) > 1 and not dest_is_dir:
514
+ raise ParamsError('multiple sources require destination directory (ending with /)')
515
+ # Count total files for progress (only if not in batch mode)
516
+ if self._verbose >= 1 and not self._batch_mode:
517
+ total_files = 0
518
+ for src in sources:
519
+ src_is_remote = src.startswith(':')
520
+ src_path = src[1:] if src_is_remote else src
521
+ if not src_path:
522
+ src_path = '/'
523
+ src_path_clean = src_path.rstrip('/') or '/'
524
+ if src_is_remote:
525
+ total_files += self._count_remote_files(src_path_clean)
526
+ else:
527
+ total_files += self._count_local_files(src_path_clean)
528
+ self._progress_total_files = total_files
529
+ self._progress_current_file = 0
530
+ for src in sources:
531
+ src_is_remote = src.startswith(':')
532
+ src_path = src[1:] if src_is_remote else src
533
+ if not src_path:
534
+ src_path = '/'
535
+ if src_is_remote and dest_is_remote:
536
+ self._cp_remote_to_remote(src_path, dest_path, dest_is_dir)
537
+ elif src_is_remote:
538
+ self._cp_remote_to_local(src_path, dest_path, dest_is_dir)
539
+ elif dest_is_remote:
540
+ self._cp_local_to_remote(src_path, dest_path, dest_is_dir)
541
+ else:
542
+ self.verbose(f"skip local-to-local: {src} -> {dest}", 2)
543
+
544
+ def cmd_mv(self, *args):
545
+ """Move/rename files on device"""
546
+ if len(args) < 2:
547
+ raise ParamsError('mv requires source and destination')
548
+ sources = list(args[:-1])
549
+ dest = args[-1]
550
+ # Validate all paths are remote
551
+ if not dest.startswith(':'):
552
+ raise ParamsError('mv destination must be device path (: prefix)')
553
+ for src in sources:
554
+ if not src.startswith(':'):
555
+ raise ParamsError('mv source must be device path (: prefix)')
556
+ dest_path = dest[1:] or '/'
557
+ dest_is_dir = dest_path.endswith('/')
558
+ if len(sources) > 1 and not dest_is_dir:
559
+ raise ParamsError('multiple sources require destination directory (ending with /)')
560
+ self._mpy.import_module('os')
561
+ for src in sources:
562
+ src_path = src[1:]
563
+ stat = self._mpy.stat(src_path)
564
+ if stat is None:
565
+ raise ParamsError(f'Source not found on device: {src_path}')
566
+ if dest_is_dir:
567
+ # Ensure destination directory exists
568
+ dst_dir = dest_path.rstrip('/')
569
+ if dst_dir and self._mpy.stat(dst_dir) is None:
570
+ self._mpy.mkdir(dst_dir)
571
+ basename = src_path.rstrip('/').split('/')[-1]
572
+ final_dest = dest_path + basename
573
+ else:
574
+ final_dest = dest_path
575
+ self.verbose(f"MV: {src_path} -> {final_dest}", 1)
576
+ self._mpy.rename(src_path, final_dest)
577
+
162
578
  def cmd_mkdir(self, *dir_names):
163
579
  for dir_name in dir_names:
164
- self.verbose(f"MKDIR: {dir_name}")
580
+ self.verbose(f"MKDIR: {dir_name}", 1)
165
581
  self._mpy.mkdir(dir_name)
166
582
 
167
583
  def cmd_delete(self, *file_names):
168
584
  for file_name in file_names:
169
- self.verbose(f"DELETE: {file_name}")
170
- self._mpy.delete(file_name)
585
+ contents_only = file_name.endswith('/')
586
+ path = file_name.rstrip('/') or '/'
587
+ if contents_only:
588
+ self.verbose(f"DELETE contents: {path}", 1)
589
+ entries = self._mpy.ls(path)
590
+ for name, size in entries:
591
+ entry_path = path + '/' + name if path != '/' else '/' + name
592
+ self.verbose(f" {entry_path}", 1)
593
+ self._mpy.delete(entry_path)
594
+ else:
595
+ self.verbose(f"DELETE: {path}", 1)
596
+ self._mpy.delete(path)
171
597
 
172
598
  def cmd_follow(self):
173
- self.verbose("FOLLOW:")
599
+ self.verbose("FOLLOW (Ctrl+C to stop)", 1)
174
600
  try:
175
601
  while True:
176
602
  line = self._conn.read_line()
177
603
  line = line.decode('utf-8', 'backslashreplace')
178
604
  print(line)
179
605
  except KeyboardInterrupt:
606
+ self.verbose('', level=0, overwrite=True) # newline after ^C
607
+ except _mpytool.ConnError as err:
180
608
  if self._log:
181
- self._log.warning(' Exiting..')
609
+ self._log.error(err)
610
+
611
+ def cmd_repl(self):
612
+ self._mpy.comm.exit_raw_repl()
613
+ if not _terminal.AVAILABLE:
614
+ self._log.error("REPL not available on this platform")
182
615
  return
616
+ self.verbose("REPL (Ctrl+] to exit)", 1)
617
+ terminal = _terminal.Terminal(self._conn, self._log)
618
+ terminal.run()
619
+ self._log.info('Exiting..')
620
+
621
+ def cmd_exec(self, code):
622
+ self.verbose(f"EXEC: {code}", 1)
623
+ result = self._mpy.comm.exec(code)
624
+ if result:
625
+ print(result.decode('utf-8', 'backslashreplace'), end='')
626
+
627
+ @staticmethod
628
+ def format_size(size):
629
+ """Format size in bytes to human readable format with 3+ digits"""
630
+ if size < 1000:
631
+ return f"{size} B"
632
+ for unit in ('KB', 'MB', 'GB', 'TB'):
633
+ size /= 1024
634
+ if size < 10:
635
+ return f"{size:.2f} {unit}"
636
+ if size < 100:
637
+ return f"{size:.1f} {unit}"
638
+ if size < 1000 or unit == 'TB':
639
+ return f"{size:.0f} {unit}"
640
+ return f"{size:.0f} TB"
641
+
642
+ def cmd_info(self):
643
+ self.verbose("INFO", 2)
644
+ self._mpy.comm.exec("import sys, gc, os")
645
+ platform = self._mpy.comm.exec_eval("repr(sys.platform)")
646
+ version = self._mpy.comm.exec_eval("repr(sys.version)")
647
+ impl = self._mpy.comm.exec_eval("repr(sys.implementation.name)")
648
+ gc_free = self._mpy.comm.exec_eval("gc.mem_free()")
649
+ gc_alloc = self._mpy.comm.exec_eval("gc.mem_alloc()")
650
+ gc_total = gc_free + gc_alloc
651
+ gc_pct = (gc_alloc / gc_total * 100) if gc_total > 0 else 0
652
+ try:
653
+ uname = self._mpy.comm.exec_eval("tuple(os.uname())")
654
+ machine = uname[4] if len(uname) > 4 else None
655
+ except _mpytool.MpyError:
656
+ machine = None
657
+ # Collect filesystem info - root and any different mount points
658
+ fs_info = []
659
+ try:
660
+ fs_stat = self._mpy.comm.exec_eval("os.statvfs('/')")
661
+ fs_total = fs_stat[0] * fs_stat[2]
662
+ fs_free = fs_stat[0] * fs_stat[3]
663
+ if fs_total > 0:
664
+ fs_info.append({
665
+ 'mount': '/', 'total': fs_total,
666
+ 'used': fs_total - fs_free,
667
+ 'pct': ((fs_total - fs_free) / fs_total * 100)
668
+ })
669
+ except _mpytool.MpyError:
670
+ pass
671
+ # Check subdirectories for additional mount points
672
+ try:
673
+ root_dirs = self._mpy.comm.exec_eval("[d[0] for d in os.ilistdir('/') if d[1] == 0x4000]")
674
+ for dirname in root_dirs:
675
+ try:
676
+ path = '/' + dirname
677
+ sub_stat = self._mpy.comm.exec_eval(f"os.statvfs('{path}')")
678
+ sub_total = sub_stat[0] * sub_stat[2]
679
+ sub_free = sub_stat[0] * sub_stat[3]
680
+ # Skip if same as root or zero size
681
+ if sub_total == 0 or any(f['total'] == sub_total for f in fs_info):
682
+ continue
683
+ fs_info.append({
684
+ 'mount': path, 'total': sub_total,
685
+ 'used': sub_total - sub_free,
686
+ 'pct': ((sub_total - sub_free) / sub_total * 100)
687
+ })
688
+ except _mpytool.MpyError:
689
+ pass
690
+ except _mpytool.MpyError:
691
+ pass
692
+ print(f"Platform: {platform}")
693
+ print(f"Version: {version}")
694
+ print(f"Impl: {impl}")
695
+ if machine:
696
+ print(f"Machine: {machine}")
697
+ print(f"Memory: {self.format_size(gc_alloc)} / {self.format_size(gc_total)} ({gc_pct:.2f}%)")
698
+ for fs in fs_info:
699
+ label = "Flash:" if fs['mount'] == '/' else fs['mount'] + ':'
700
+ print(f"{label:12} {self.format_size(fs['used'])} / {self.format_size(fs['total'])} ({fs['pct']:.2f}%)")
183
701
 
184
702
  def process_commands(self, commands):
185
703
  try:
@@ -218,94 +736,177 @@ class MpyTool():
218
736
  elif command == 'mkdir':
219
737
  self.cmd_mkdir(*commands)
220
738
  break
221
- elif command in ('del', 'delete'):
739
+ elif command in ('del', 'delete', 'rm'):
222
740
  self.cmd_delete(*commands)
223
741
  break
224
742
  elif command == 'reset':
743
+ self.verbose("RESET", 1)
225
744
  self._mpy.comm.soft_reset()
745
+ self._mpy.reset_state()
226
746
  elif command == 'follow':
227
747
  self.cmd_follow()
228
748
  break
749
+ elif command == 'repl':
750
+ self.cmd_repl()
751
+ break
752
+ elif command == 'exec':
753
+ if commands:
754
+ code = commands.pop(0)
755
+ self.cmd_exec(code)
756
+ else:
757
+ raise ParamsError('missing code for exec command')
758
+ elif command == 'info':
759
+ self.cmd_info()
760
+ elif command == 'cp':
761
+ if len(commands) >= 2:
762
+ self.cmd_cp(*commands)
763
+ break
764
+ raise ParamsError('cp requires source and destination')
765
+ elif command == 'mv':
766
+ if len(commands) >= 2:
767
+ self.cmd_mv(*commands)
768
+ break
769
+ raise ParamsError('mv requires source and destination')
229
770
  else:
230
771
  raise ParamsError(f"unknown command: '{command}'")
231
- except _mpytool.MpyError as err:
232
- if self._log:
233
- self._log.error(err)
234
- else:
235
- print(err)
236
- self._mpy.comm.exit_raw_repl()
237
-
238
-
239
- class SimpleColorLogger():
240
- def __init__(self, loglevel=0):
241
- self._loglevel = loglevel
242
-
243
- def log(self, msg):
244
- print(msg, file=_sys.stderr)
245
-
246
- def error(self, msg):
247
- if self._loglevel >= 1:
248
- self.log(f"\033[1;31m{msg}\033[0m")
249
-
250
- def warning(self, msg):
251
- if self._loglevel >= 2:
252
- self.log(f"\033[1;33m{msg}\033[0m")
253
-
254
- def info(self, msg):
255
- if self._loglevel >= 3:
256
- self.log(f"\033[1;35m{msg}\033[0m")
257
-
258
- def debug(self, msg):
259
- if self._loglevel >= 4:
260
- self.log(f"\033[1;34m{msg}\033[0m")
772
+ except (_mpytool.MpyError, _mpytool.ConnError) as err:
773
+ self._log.error(err)
774
+ try:
775
+ self._mpy.comm.exit_raw_repl()
776
+ except _mpytool.ConnError:
777
+ pass # connection already lost
261
778
 
262
779
 
263
- _VERSION_STR = "%s %s (%s <%s>)" % (
264
- _about.APP_NAME,
265
- _about.VERSION,
266
- _about.AUTHOR,
267
- _about.AUTHOR_EMAIL)
780
+ if _about:
781
+ _VERSION_STR = "%s %s (%s)" % (_about["Name"], _about["Version"], _about["Author-email"])
782
+ else:
783
+ _VERSION_STR = "mpytool (not installed version)"
268
784
  _COMMANDS_HELP_STR = """
269
785
  List of available commands:
270
786
  ls [{path}] list files and its sizes
271
787
  tree [{path}] list tree of structure and sizes
788
+ cp {src} [...] {dst} copy files (: prefix = device path)
789
+ mv {src} [...] {dst} move/rename on device (: prefix required)
272
790
  get {path} [...] get file and print it
273
791
  put {src_path} [{dst_path}] put file or directory to destination
274
792
  mkdir {path} [...] create directory (also create all parents)
275
- delete {path} [...] remove file or directory (recursively)
793
+ delete {path} [...] remove file/dir (path/ = contents only)
276
794
  reset soft reset
277
795
  follow print log of running program
796
+ repl enter REPL mode [UNIX OS ONLY]
797
+ exec {code} execute Python code on device
798
+ info show device information
278
799
  Aliases:
279
800
  dir alias to ls
280
801
  cat alias to get
281
- del alias to delete
802
+ del, rm alias to delete
803
+ Use -- to separate multiple commands:
804
+ mpytool put main.py / -- reset -- follow
282
805
  """
283
806
 
284
807
 
808
+ def _run_commands(mpy_tool, command_groups, with_progress=True):
809
+ """Execute command groups with optional batch progress tracking"""
810
+ if not with_progress:
811
+ for commands in command_groups:
812
+ mpy_tool.process_commands(commands)
813
+ return
814
+ # Pre-scan to identify consecutive copy command batches (for progress)
815
+ i = 0
816
+ while i < len(command_groups):
817
+ is_copy, count, paths = mpy_tool.count_files_for_command(command_groups[i])
818
+ if not is_copy:
819
+ mpy_tool.process_commands(command_groups[i])
820
+ i += 1
821
+ continue
822
+ # Collect consecutive copy commands into a batch
823
+ batch_total = count
824
+ all_paths = paths
825
+ batch_start = i
826
+ j = i + 1
827
+ while j < len(command_groups):
828
+ is_copy_j, count_j, paths_j = mpy_tool.count_files_for_command(command_groups[j])
829
+ if not is_copy_j:
830
+ break
831
+ batch_total += count_j
832
+ all_paths.extend(paths_j)
833
+ j += 1
834
+ # Execute batch with combined count
835
+ max_src_len = max(len(p) for p in all_paths) if all_paths else 0
836
+ mpy_tool.verbose("COPY", 1)
837
+ mpy_tool.set_batch_progress(batch_total, max_src_len)
838
+ for k in range(batch_start, j):
839
+ mpy_tool.process_commands(command_groups[k])
840
+ mpy_tool.reset_batch_progress()
841
+ i = j
842
+
843
+
285
844
  def main():
286
845
  """Main"""
846
+ _description = _about["Summary"] if _about else None
287
847
  parser = _argparse.ArgumentParser(
848
+ description=_description,
288
849
  formatter_class=_argparse.RawTextHelpFormatter,
289
850
  epilog=_COMMANDS_HELP_STR)
290
851
  parser.add_argument(
291
852
  "-V", "--version", action='version', version=_VERSION_STR)
292
- parser.add_argument('-p', '--port', required=True, help="serial port")
853
+ parser.add_argument('-p', '--port', help="serial port")
854
+ parser.add_argument('-a', '--address', help="network address")
855
+ parser.add_argument('-b', '--baud', type=int, default=115200, help="serial port")
293
856
  parser.add_argument(
294
857
  '-d', '--debug', default=0, action='count', help='set debug level')
295
858
  parser.add_argument(
296
- '-v', '--verbose', default=0, action='count', help='verbose output')
859
+ '-v', '--verbose', action='store_true', help='verbose output (show commands)')
860
+ parser.add_argument(
861
+ '-q', '--quiet', action='store_true', help='quiet mode (no progress)')
862
+ parser.add_argument(
863
+ '-f', '--force', action='store_true', help='force copy even if unchanged')
297
864
  parser.add_argument(
298
865
  "-e", "--exclude-dir", type=str, action='append', help='exclude dir, '
299
866
  'by default are excluded directories: __pycache__, .git, .svn')
300
- parser.add_argument('commands', nargs='*', help='commands')
867
+ parser.add_argument('commands', nargs=_argparse.REMAINDER, help='commands')
301
868
  args = parser.parse_args()
302
-
303
- log = SimpleColorLogger(args.debug + 1)
304
- conn = _mpytool.ConnSerial(
305
- port=args.port, baudrate=115200, log=log)
306
- mpy_tool = MpyTool(
307
- conn, log=log, verbose=args.verbose, exclude_dirs=args.exclude_dir)
308
- mpy_tool.process_commands(args.commands)
869
+ # Convert to numeric level: 0=quiet, 1=progress, 2=verbose
870
+ if args.quiet:
871
+ args.verbose = 0
872
+ elif args.verbose:
873
+ args.verbose = 2
874
+ else:
875
+ args.verbose = 1
876
+
877
+ log = SimpleColorLogger(args.debug + 1, verbose_level=args.verbose)
878
+ if args.port and args.address:
879
+ log.error("You can select only serial port or network address")
880
+ return
881
+ port = args.port
882
+ if not port and not args.address:
883
+ ports = _utils.detect_serial_ports()
884
+ if not ports:
885
+ log.error("No serial port found. Use -p to specify port.")
886
+ return
887
+ if len(ports) == 1:
888
+ port = ports[0]
889
+ log.verbose(f"Using {port}", level=1)
890
+ else:
891
+ log.error("Multiple serial ports found: %s. Use -p to specify one.", ", ".join(ports))
892
+ return
893
+ try:
894
+ if port:
895
+ conn = _mpytool.ConnSerial(
896
+ port=port, baudrate=args.baud, log=log)
897
+ elif args.address:
898
+ conn = _mpytool.ConnSocket(
899
+ address=args.address, log=log)
900
+ except _mpytool.ConnError as err:
901
+ log.error(err)
902
+ return
903
+ mpy_tool = MpyTool(conn, log=log, verbose=log, exclude_dirs=args.exclude_dir, force=args.force)
904
+ command_groups = _utils.split_commands(args.commands)
905
+ try:
906
+ _run_commands(mpy_tool, command_groups, with_progress=(args.verbose >= 1))
907
+ except KeyboardInterrupt:
908
+ # Clear partial progress line and show clean message
909
+ log.verbose('Interrupted', level=0, overwrite=True)
309
910
 
310
911
 
311
912
  if __name__ == '__main__':