mpytool 1.2.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,35 +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
8
  import mpytool.terminal as _terminal
8
- import mpytool.__about__ as _about
9
+ import mpytool.utils as _utils
10
+ from mpytool.logger import SimpleColorLogger
11
+ import importlib.metadata as _metadata
9
12
 
10
-
11
- class ParamsError(_mpytool.MpyError):
12
- """Timeout"""
13
-
14
-
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
- def __str__(self):
22
- return f"Path '{self._file_name}' was not found"
13
+ try:
14
+ _about = _metadata.metadata("mpytool")
15
+ except _metadata.PackageNotFoundError:
16
+ _about = None
23
17
 
24
18
 
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"
19
+ class ParamsError(_mpytool.MpyError):
20
+ """Invalid command parameters"""
35
21
 
36
22
 
37
23
  class MpyTool():
@@ -40,18 +26,230 @@ class MpyTool():
40
26
  TEE = '├─ '
41
27
  LAST = '└─ '
42
28
 
43
- 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):
44
30
  self._conn = conn
45
- self._log = log
46
- 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)
47
33
  self._exclude_dirs = {'__pycache__', '.git', '.svn'}
48
34
  if exclude_dirs:
49
35
  self._exclude_dirs.update(exclude_dirs)
50
- 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)
51
115
 
52
- def verbose(self, msg, level=1):
53
- if self._verbose >= level:
54
- 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
55
253
 
56
254
  def cmd_ls(self, dir_name):
57
255
  result = self._mpy.ls(dir_name)
@@ -108,16 +306,17 @@ class MpyTool():
108
306
 
109
307
  def cmd_get(self, *file_names):
110
308
  for file_name in file_names:
111
- self.verbose(f"GET: {file_name}")
309
+ self.verbose(f"GET: {file_name}", 2)
112
310
  data = self._mpy.get(file_name)
113
311
  print(data.decode('utf-8'))
114
312
 
115
- def _put_dir(self, src_path, dst_path):
313
+ def _put_dir(self, src_path, dst_path, show_progress=True):
116
314
  basename = _os.path.basename(src_path)
117
315
  if basename:
118
316
  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):
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]
121
320
  basename = _os.path.basename(path)
122
321
  if basename in self._exclude_dirs:
123
322
  continue
@@ -126,33 +325,67 @@ class MpyTool():
126
325
  rel_path = ''
127
326
  rel_path = _os.path.join(dst_path, rel_path)
128
327
  if rel_path:
129
- self.verbose(f'mkdir: {rel_path}', 2)
328
+ self.verbose(f'MKDIR: {rel_path}', 2)
130
329
  self._mpy.mkdir(rel_path)
131
330
  for file_name in files:
132
331
  spath = _os.path.join(path, file_name)
133
332
  dpath = _os.path.join(rel_path, file_name)
134
- self.verbose(f" {dpath}")
135
333
  with open(spath, 'rb') as src_file:
136
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:
137
349
  self._mpy.put(data, dpath)
138
350
 
139
- def _put_file(self, src_path, dst_path):
351
+ def _put_file(self, src_path, dst_path, show_progress=True):
140
352
  basename = _os.path.basename(src_path)
141
353
  if basename and not _os.path.basename(dst_path):
142
354
  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}')
355
+ self.verbose(f"PUT FILE: {src_path} -> {dst_path}", 2)
356
+ # Read local file
151
357
  with open(src_path, 'rb') as src_file:
152
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:
153
383
  self._mpy.put(data, dst_path)
154
384
 
155
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
156
389
  if _os.path.isdir(src_path):
157
390
  self._put_dir(src_path, dst_path)
158
391
  elif _os.path.isfile(src_path):
@@ -160,38 +393,311 @@ class MpyTool():
160
393
  else:
161
394
  raise ParamsError(f'No file or directory to upload: {src_path}')
162
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
+
163
578
  def cmd_mkdir(self, *dir_names):
164
579
  for dir_name in dir_names:
165
- self.verbose(f"MKDIR: {dir_name}")
580
+ self.verbose(f"MKDIR: {dir_name}", 1)
166
581
  self._mpy.mkdir(dir_name)
167
582
 
168
583
  def cmd_delete(self, *file_names):
169
584
  for file_name in file_names:
170
- self.verbose(f"DELETE: {file_name}")
171
- 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)
172
597
 
173
598
  def cmd_follow(self):
174
- self.verbose("FOLLOW:")
599
+ self.verbose("FOLLOW (Ctrl+C to stop)", 1)
175
600
  try:
176
601
  while True:
177
602
  line = self._conn.read_line()
178
603
  line = line.decode('utf-8', 'backslashreplace')
179
604
  print(line)
180
605
  except KeyboardInterrupt:
606
+ self.verbose('', level=0, overwrite=True) # newline after ^C
607
+ except _mpytool.ConnError as err:
181
608
  if self._log:
182
- self._log.warning(' Exiting..')
183
- return
609
+ self._log.error(err)
184
610
 
185
611
  def cmd_repl(self):
186
- self.verbose("REPL:")
187
612
  self._mpy.comm.exit_raw_repl()
188
613
  if not _terminal.AVAILABLE:
189
614
  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..')
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}%)")
195
701
 
196
702
  def process_commands(self, commands):
197
703
  try:
@@ -230,98 +736,177 @@ class MpyTool():
230
736
  elif command == 'mkdir':
231
737
  self.cmd_mkdir(*commands)
232
738
  break
233
- elif command in ('del', 'delete'):
739
+ elif command in ('del', 'delete', 'rm'):
234
740
  self.cmd_delete(*commands)
235
741
  break
236
742
  elif command == 'reset':
743
+ self.verbose("RESET", 1)
237
744
  self._mpy.comm.soft_reset()
745
+ self._mpy.reset_state()
238
746
  elif command == 'follow':
239
747
  self.cmd_follow()
240
748
  break
241
749
  elif command == 'repl':
242
750
  self.cmd_repl()
243
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')
244
770
  else:
245
771
  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")
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
276
778
 
277
779
 
278
- _VERSION_STR = "%s %s (%s <%s>)" % (
279
- _about.APP_NAME,
280
- _about.VERSION,
281
- _about.AUTHOR,
282
- _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)"
283
784
  _COMMANDS_HELP_STR = """
284
785
  List of available commands:
285
786
  ls [{path}] list files and its sizes
286
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)
287
790
  get {path} [...] get file and print it
288
791
  put {src_path} [{dst_path}] put file or directory to destination
289
792
  mkdir {path} [...] create directory (also create all parents)
290
- delete {path} [...] remove file or directory (recursively)
793
+ delete {path} [...] remove file/dir (path/ = contents only)
291
794
  reset soft reset
292
795
  follow print log of running program
293
796
  repl enter REPL mode [UNIX OS ONLY]
797
+ exec {code} execute Python code on device
798
+ info show device information
294
799
  Aliases:
295
800
  dir alias to ls
296
801
  cat alias to get
297
- del alias to delete
802
+ del, rm alias to delete
803
+ Use -- to separate multiple commands:
804
+ mpytool put main.py / -- reset -- follow
298
805
  """
299
806
 
300
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
+
301
844
  def main():
302
845
  """Main"""
846
+ _description = _about["Summary"] if _about else None
303
847
  parser = _argparse.ArgumentParser(
848
+ description=_description,
304
849
  formatter_class=_argparse.RawTextHelpFormatter,
305
850
  epilog=_COMMANDS_HELP_STR)
306
851
  parser.add_argument(
307
852
  "-V", "--version", action='version', version=_VERSION_STR)
308
- 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")
309
856
  parser.add_argument(
310
857
  '-d', '--debug', default=0, action='count', help='set debug level')
311
858
  parser.add_argument(
312
- '-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')
313
864
  parser.add_argument(
314
865
  "-e", "--exclude-dir", type=str, action='append', help='exclude dir, '
315
866
  'by default are excluded directories: __pycache__, .git, .svn')
316
- parser.add_argument('commands', nargs='*', help='commands')
867
+ parser.add_argument('commands', nargs=_argparse.REMAINDER, help='commands')
317
868
  args = parser.parse_args()
318
-
319
- log = SimpleColorLogger(args.debug + 1)
320
- conn = _mpytool.ConnSerial(
321
- port=args.port, baudrate=115200, log=log)
322
- mpy_tool = MpyTool(
323
- conn, log=log, verbose=args.verbose, exclude_dirs=args.exclude_dir)
324
- 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)
325
910
 
326
911
 
327
912
  if __name__ == '__main__':