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/__init__.py +2 -0
- mpytool/conn.py +73 -11
- mpytool/conn_serial.py +15 -48
- mpytool/conn_socket.py +53 -0
- mpytool/logger.py +87 -0
- mpytool/mpy.py +79 -14
- mpytool/mpy_comm.py +44 -15
- mpytool/mpytool.py +691 -106
- mpytool/terminal.py +42 -13
- mpytool/utils.py +82 -0
- mpytool-2.0.0.dist-info/METADATA +233 -0
- mpytool-2.0.0.dist-info/RECORD +16 -0
- {mpytool-1.2.0.dist-info → mpytool-2.0.0.dist-info}/WHEEL +1 -1
- {mpytool-1.2.0.dist-info → mpytool-2.0.0.dist-info}/entry_points.txt +0 -1
- mpytool/__about__.py +0 -12
- mpytool-1.2.0.dist-info/METADATA +0 -23
- mpytool-1.2.0.dist-info/RECORD +0 -14
- {mpytool-1.2.0.dist-info → mpytool-2.0.0.dist-info/licenses}/LICENSE +0 -0
- {mpytool-1.2.0.dist-info → mpytool-2.0.0.dist-info}/top_level.txt +0 -0
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.
|
|
9
|
+
import mpytool.utils as _utils
|
|
10
|
+
from mpytool.logger import SimpleColorLogger
|
|
11
|
+
import importlib.metadata as _metadata
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
26
|
-
"""
|
|
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=
|
|
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.
|
|
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=
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
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"
|
|
120
|
-
for 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'
|
|
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"
|
|
144
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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.
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
terminal.
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
279
|
-
_about
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
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
|
|
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',
|
|
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',
|
|
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=
|
|
867
|
+
parser.add_argument('commands', nargs=_argparse.REMAINDER, help='commands')
|
|
317
868
|
args = parser.parse_args()
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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__':
|