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/__init__.py +2 -0
- mpytool/conn.py +78 -5
- mpytool/conn_serial.py +21 -36
- 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 +700 -99
- mpytool/terminal.py +76 -0
- 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.1.0.dist-info → mpytool-2.0.0.dist-info}/WHEEL +1 -1
- {mpytool-1.1.0.dist-info → mpytool-2.0.0.dist-info}/entry_points.txt +0 -1
- mpytool/__about__.py +0 -12
- mpytool-1.1.0.dist-info/METADATA +0 -23
- mpytool-1.1.0.dist-info/RECORD +0 -13
- {mpytool-1.1.0.dist-info → mpytool-2.0.0.dist-info/licenses}/LICENSE +0 -0
- {mpytool-1.1.0.dist-info → mpytool-2.0.0.dist-info}/top_level.txt +0 -0
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.
|
|
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
|
-
|
|
11
|
-
|
|
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
|
|
25
|
-
"""
|
|
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=
|
|
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.
|
|
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=
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
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"
|
|
119
|
-
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]
|
|
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'
|
|
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"
|
|
143
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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.
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
264
|
-
_about
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
|
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',
|
|
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',
|
|
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=
|
|
867
|
+
parser.add_argument('commands', nargs=_argparse.REMAINDER, help='commands')
|
|
301
868
|
args = parser.parse_args()
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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__':
|