mpytool 2.2.0__tar.gz → 2.2.2__tar.gz

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.
Files changed (29) hide show
  1. {mpytool-2.2.0 → mpytool-2.2.2}/PKG-INFO +10 -2
  2. {mpytool-2.2.0 → mpytool-2.2.2}/README.md +9 -1
  3. {mpytool-2.2.0 → mpytool-2.2.2}/mpytool/conn.py +3 -0
  4. {mpytool-2.2.0 → mpytool-2.2.2}/mpytool/conn_serial.py +23 -1
  5. {mpytool-2.2.0 → mpytool-2.2.2}/mpytool/conn_socket.py +5 -1
  6. {mpytool-2.2.0 → mpytool-2.2.2}/mpytool/logger.py +22 -9
  7. {mpytool-2.2.0 → mpytool-2.2.2}/mpytool/mpy_comm.py +29 -0
  8. {mpytool-2.2.0 → mpytool-2.2.2}/mpytool/mpytool.py +65 -34
  9. mpytool-2.2.2/mpytool/terminal.py +80 -0
  10. mpytool-2.2.2/mpytool/terminal_unix.py +45 -0
  11. mpytool-2.2.2/mpytool/terminal_win.py +88 -0
  12. {mpytool-2.2.0 → mpytool-2.2.2}/mpytool/utils.py +5 -1
  13. {mpytool-2.2.0 → mpytool-2.2.2}/mpytool.egg-info/PKG-INFO +10 -2
  14. {mpytool-2.2.0 → mpytool-2.2.2}/mpytool.egg-info/SOURCES.txt +2 -0
  15. {mpytool-2.2.0 → mpytool-2.2.2}/pyproject.toml +1 -1
  16. {mpytool-2.2.0 → mpytool-2.2.2}/tests/test_errors.py +31 -0
  17. {mpytool-2.2.0 → mpytool-2.2.2}/tests/test_integration.py +22 -1
  18. {mpytool-2.2.0 → mpytool-2.2.2}/tests/test_utils.py +6 -4
  19. mpytool-2.2.0/mpytool/terminal.py +0 -76
  20. {mpytool-2.2.0 → mpytool-2.2.2}/LICENSE +0 -0
  21. {mpytool-2.2.0 → mpytool-2.2.2}/mpytool/__init__.py +0 -0
  22. {mpytool-2.2.0 → mpytool-2.2.2}/mpytool/mpy.py +0 -0
  23. {mpytool-2.2.0 → mpytool-2.2.2}/mpytool.egg-info/dependency_links.txt +0 -0
  24. {mpytool-2.2.0 → mpytool-2.2.2}/mpytool.egg-info/entry_points.txt +0 -0
  25. {mpytool-2.2.0 → mpytool-2.2.2}/mpytool.egg-info/requires.txt +0 -0
  26. {mpytool-2.2.0 → mpytool-2.2.2}/mpytool.egg-info/top_level.txt +0 -0
  27. {mpytool-2.2.0 → mpytool-2.2.2}/setup.cfg +0 -0
  28. {mpytool-2.2.0 → mpytool-2.2.2}/tests/test_mpy.py +0 -0
  29. {mpytool-2.2.0 → mpytool-2.2.2}/tests/test_mpytool.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mpytool
3
- Version: 2.2.0
3
+ Version: 2.2.2
4
4
  Summary: MPY tool - manage files on devices running MicroPython
5
5
  Author-email: Pavel Revak <pavel.revak@gmail.com>
6
6
  License-Expression: MIT
@@ -446,7 +446,15 @@ Working only with MicroPython boards, not with CircuitPython
446
446
 
447
447
  - Linux
448
448
  - MacOS
449
- - Windows (limited support - REPL mode is disabled)
449
+ - Windows
450
+
451
+ ### Windows notes
452
+
453
+ All commands work on Windows including `repl`.
454
+
455
+ **CMD.EXE**: ANSI colors are disabled, progress indicator works. Log messages use text prefixes (`E:`, `W:`, `I:`, `D:`).
456
+
457
+ **Git Bash**: Full color support (sets `TERM` environment variable).
450
458
 
451
459
  ## Credits
452
460
 
@@ -432,7 +432,15 @@ Working only with MicroPython boards, not with CircuitPython
432
432
 
433
433
  - Linux
434
434
  - MacOS
435
- - Windows (limited support - REPL mode is disabled)
435
+ - Windows
436
+
437
+ ### Windows notes
438
+
439
+ All commands work on Windows including `repl`.
440
+
441
+ **CMD.EXE**: ANSI colors are disabled, progress indicator works. Log messages use text prefixes (`E:`, `W:`, `I:`, `D:`).
442
+
443
+ **Git Bash**: Full color support (sets `TERM` environment variable).
436
444
 
437
445
  ## Credits
438
446
 
@@ -116,6 +116,9 @@ class Conn():
116
116
  line = self.read_until(b'\n', timeout)
117
117
  return line.strip(b'\r')
118
118
 
119
+ def close(self):
120
+ """Close connection"""
121
+
119
122
  def hard_reset(self):
120
123
  """Hardware reset (only available on serial connections)"""
121
124
  raise NotImplementedError("Hardware reset not available on this connection")
@@ -17,15 +17,37 @@ class ConnSerial(_conn.Conn):
17
17
  self._serial = None
18
18
  raise _conn.ConnError(
19
19
  f"Error opening serial port {serial_config['port']}") from err
20
+ # Windows: pyserial has no fd, select() works only with sockets
21
+ if not hasattr(self._serial, 'fd'):
22
+ self._has_data = self._has_data_polling
20
23
 
21
- def __del__(self):
24
+ def close(self):
22
25
  if self._serial:
23
26
  self._serial.close()
27
+ self._serial = None
28
+
29
+ def __del__(self):
30
+ self.close()
24
31
 
25
32
  @property
26
33
  def fd(self):
27
34
  return self._serial.fd if self._serial else None
28
35
 
36
+ def _has_data_polling(self, timeout=0):
37
+ """Check if data is available using in_waiting (Windows)"""
38
+ try:
39
+ if self._serial.in_waiting > 0:
40
+ return True
41
+ if timeout > 0:
42
+ deadline = _time.time() + timeout
43
+ while _time.time() < deadline:
44
+ if self._serial.in_waiting > 0:
45
+ return True
46
+ _time.sleep(0.001)
47
+ return False
48
+ except OSError:
49
+ return False
50
+
29
51
  def _read_available(self):
30
52
  """Read available data from serial port"""
31
53
  if self._serial is None:
@@ -30,9 +30,13 @@ class ConnSocket(_conn.Conn):
30
30
  if log:
31
31
  log.info("connected")
32
32
 
33
- def __del__(self):
33
+ def close(self):
34
34
  if self._socket:
35
35
  self._socket.close()
36
+ self._socket = None
37
+
38
+ def __del__(self):
39
+ self.close()
36
40
 
37
41
  @property
38
42
  def fd(self):
@@ -28,21 +28,31 @@ class SimpleColorLogger():
28
28
  def __init__(self, loglevel=1, verbose_level=0):
29
29
  self._loglevel = loglevel
30
30
  self._verbose_level = verbose_level
31
- self._is_tty = (
32
- _sys.stderr.isatty()
31
+ self._is_tty = _sys.stderr.isatty()
32
+ self._color = (
33
+ self._is_tty
33
34
  and _os.environ.get('NO_COLOR') is None
34
35
  and _os.environ.get('TERM') != 'dumb'
35
36
  and _os.environ.get('CI') is None
37
+ and (_sys.platform != 'win32' or _os.environ.get('TERM'))
36
38
  )
39
+ self._pending_line = False # True when last output had no trailing newline
40
+
41
+ def _clear_pending(self):
42
+ """End pending progress line before printing a new message"""
43
+ if self._pending_line:
44
+ print(file=_sys.stderr)
45
+ self._pending_line = False
37
46
 
38
47
  def log(self, msg):
48
+ self._clear_pending()
39
49
  print(msg, file=_sys.stderr)
40
50
 
41
51
  def error(self, msg, *args):
42
52
  if args:
43
53
  msg = msg % args
44
54
  if self._loglevel >= 1:
45
- if self._is_tty:
55
+ if self._color:
46
56
  self.log(f"{self._BOLD_RED}{msg}{self._RESET}")
47
57
  else:
48
58
  self.log(f"E: {msg}")
@@ -51,7 +61,7 @@ class SimpleColorLogger():
51
61
  if args:
52
62
  msg = msg % args
53
63
  if self._loglevel >= 2:
54
- if self._is_tty:
64
+ if self._color:
55
65
  self.log(f"{self._BOLD_YELLOW}{msg}{self._RESET}")
56
66
  else:
57
67
  self.log(f"W: {msg}")
@@ -60,7 +70,7 @@ class SimpleColorLogger():
60
70
  if args:
61
71
  msg = msg % args
62
72
  if self._loglevel >= 3:
63
- if self._is_tty:
73
+ if self._color:
64
74
  self.log(f"{self._BOLD_MAGENTA}{msg}{self._RESET}")
65
75
  else:
66
76
  self.log(f"I: {msg}")
@@ -69,7 +79,7 @@ class SimpleColorLogger():
69
79
  if args:
70
80
  msg = msg % args
71
81
  if self._loglevel >= 4:
72
- if self._is_tty:
82
+ if self._color:
73
83
  self.log(f"{self._BOLD_BLUE}{msg}{self._RESET}")
74
84
  else:
75
85
  self.log(f"D: {msg}")
@@ -81,7 +91,10 @@ class SimpleColorLogger():
81
91
  # Skip progress updates (overwrite without newline) in non-TTY mode
82
92
  if overwrite and not self._is_tty and end != '\n':
83
93
  return
84
- color_code = self.COLORS.get(color, self._BOLD_GREEN) if self._is_tty else ''
85
- reset_code = self._RESET if self._is_tty else ''
86
- clear = f'\r{self._CLEAR_LINE}' if self._is_tty and overwrite else ''
94
+ if not overwrite:
95
+ self._clear_pending()
96
+ color_code = self.COLORS.get(color, self._BOLD_GREEN) if self._color else ''
97
+ reset_code = self._RESET if self._color else ''
98
+ clear = f'\r{self._CLEAR_LINE}' if self._color and overwrite else ('\r' if self._is_tty and overwrite else '')
87
99
  print(f"{clear}{color_code}{msg}{reset_code}", end=end, file=_sys.stderr, flush=True)
100
+ self._pending_line = (end != '\n')
@@ -20,13 +20,42 @@ class MpyError(Exception):
20
20
 
21
21
  class CmdError(MpyError):
22
22
  """Command execution error on device"""
23
+
24
+ # Known MicroPython OSError codes
25
+ _OSERROR_MESSAGES = {
26
+ '2': 'No such file or directory',
27
+ '13': 'Permission denied',
28
+ '17': 'File exists',
29
+ '19': 'No such device',
30
+ '21': 'Is a directory',
31
+ '22': 'Invalid argument',
32
+ '28': 'No space left on device',
33
+ '30': 'Read-only filesystem',
34
+ '110': 'Connection timed out',
35
+ '113': 'No route to host',
36
+ }
37
+
23
38
  def __init__(self, cmd, result, error):
24
39
  self._cmd = cmd
25
40
  self._result = result
26
41
  self._error = error.decode('utf-8')
27
42
  super().__init__(self.__str__())
28
43
 
44
+ def _friendly_error(self):
45
+ """Translate known OSError codes to human-readable messages"""
46
+ import re
47
+ match = re.search(r'OSError: (\d+)', self._error)
48
+ if match:
49
+ code = match.group(1)
50
+ msg = self._OSERROR_MESSAGES.get(code)
51
+ if msg:
52
+ return f'OSError: {msg} (errno {code})'
53
+ return None
54
+
29
55
  def __str__(self):
56
+ friendly = self._friendly_error()
57
+ if friendly:
58
+ return friendly
30
59
  res = f'Command:\n {self._cmd}\n'
31
60
  if self._result:
32
61
  res += f'Result:\n {self._result}\n'
@@ -30,7 +30,7 @@ def _join_remote_path(base, name):
30
30
  if base == '/':
31
31
  return '/' + name
32
32
  elif base:
33
- return base + '/' + name
33
+ return base.rstrip('/') + '/' + name
34
34
  else:
35
35
  return name
36
36
 
@@ -119,7 +119,10 @@ class MpyTool():
119
119
  chunk_str = f"{chunk // 1024}K" if chunk >= 1024 else str(chunk)
120
120
  compress = self._mpy._detect_deflate() if self._compress is None else self._compress
121
121
  compress_str = "on" if compress else "off"
122
- self.verbose(f"COPY (chunk: {chunk_str}, compress: {compress_str})", 1)
122
+ if self._verbose >= 2:
123
+ self.verbose(f"COPY (chunk: {chunk_str}, compress: {compress_str})")
124
+ else:
125
+ self.verbose("COPY")
123
126
 
124
127
  @staticmethod
125
128
  def _format_local_path(path):
@@ -157,7 +160,7 @@ class MpyTool():
157
160
  return info
158
161
 
159
162
  def _format_line(self, status, total, encodings=None):
160
- """Format progress/skip line: [2/5] 100% 24.1K source -> dest (base64)"""
163
+ """Format verbose line: [2/5] 100% 24.1K source -> dest (base64)"""
161
164
  size_str = self.format_size(total)
162
165
  multi = self._progress_total_files > 1
163
166
  if multi:
@@ -170,6 +173,22 @@ class MpyTool():
170
173
  enc = self._format_encoding_info(encodings, pad=multi) if encodings else (" " * self._ENC_WIDTH if multi else "")
171
174
  return f"{prefix:>7} {status} {size_str:>5} {self._progress_src:<{src_w}} -> {self._progress_dst:<{dst_w}}{enc}"
172
175
 
176
+ def _format_compact_progress(self, status, total):
177
+ """Format compact progress line: [2/5] 45% 24.1K source"""
178
+ size_str = self.format_size(total)
179
+ multi = self._progress_total_files > 1
180
+ if multi:
181
+ width = len(str(self._progress_total_files))
182
+ prefix = f"[{self._progress_current_file:>{width}}/{self._progress_total_files}]"
183
+ else:
184
+ prefix = ""
185
+ return f"{prefix:>7} {status} {size_str:>5} {self._progress_src}"
186
+
187
+ def _format_compact_complete(self, total):
188
+ """Format compact complete line: 24.1K source -> dest"""
189
+ size_str = self.format_size(total)
190
+ return f" {size_str:>5} {self._progress_src} -> {self._progress_dst}"
191
+
173
192
  def _format_progress_line(self, percent, total, encodings=None):
174
193
  return self._format_line(f"{percent:3d}%", total, encodings)
175
194
 
@@ -179,19 +198,22 @@ class MpyTool():
179
198
  def _progress_callback(self, transferred, total):
180
199
  """Callback for file transfer progress"""
181
200
  percent = (transferred * 100 // total) if total > 0 else 100
182
- line = self._format_progress_line(percent, total)
201
+ if self._verbose >= 2:
202
+ line = self._format_progress_line(percent, total)
203
+ else:
204
+ line = self._format_compact_progress(f"{percent:3d}%", total)
183
205
  if self._is_debug:
184
- # Debug mode: always newlines
185
206
  self.verbose(line, color='cyan')
186
207
  else:
187
- # Normal mode: overwrite line
188
208
  self.verbose(line, color='cyan', end='', overwrite=True)
189
209
 
190
210
  def _progress_complete(self, total, encodings=None):
191
211
  """Mark current file as complete"""
192
- line = self._format_progress_line(100, total, encodings)
212
+ if self._verbose >= 2:
213
+ line = self._format_progress_line(100, total, encodings)
214
+ else:
215
+ line = self._format_compact_complete(total)
193
216
  if self._is_debug:
194
- # Already printed with newline in callback
195
217
  pass
196
218
  else:
197
219
  self.verbose(line, color='cyan', overwrite=True)
@@ -258,23 +280,24 @@ class MpyTool():
258
280
  # For files, add basename if dst_path ends with /
259
281
  basename = _os.path.basename(src_path)
260
282
  if basename and not _os.path.basename(dst_path):
261
- dst_path = _os.path.join(dst_path, basename)
283
+ dst_path = _join_remote_path(dst_path, basename)
262
284
  files[dst_path] = _os.path.getsize(src_path)
263
285
  elif _os.path.isdir(src_path):
264
286
  # For directories, mimic _put_dir behavior
265
287
  if add_src_basename:
266
288
  basename = _os.path.basename(_os.path.abspath(src_path))
267
289
  if basename:
268
- dst_path = _os.path.join(dst_path, basename)
290
+ dst_path = _join_remote_path(dst_path, basename)
269
291
  for root, dirs, filenames in _os.walk(src_path, topdown=True):
270
- dirs[:] = [d for d in dirs if not self._is_excluded(d)]
271
- filenames = [f for f in filenames if not self._is_excluded(f)]
272
- rel_path = _os.path.relpath(root, src_path)
292
+ dirs[:] = sorted(d for d in dirs if not self._is_excluded(d))
293
+ filenames = sorted(f for f in filenames if not self._is_excluded(f))
294
+ rel_path = _os.path.relpath(root, src_path).replace(_os.sep, '/')
273
295
  if rel_path == '.':
274
296
  rel_path = ''
275
297
  for file_name in filenames:
276
298
  spath = _os.path.join(root, file_name)
277
- dpath = _os.path.join(dst_path, rel_path, file_name) if rel_path else _os.path.join(dst_path, file_name)
299
+ dpath = _join_remote_path(
300
+ _join_remote_path(dst_path, rel_path), file_name)
278
301
  files[dpath] = _os.path.getsize(spath)
279
302
  return files
280
303
 
@@ -466,10 +489,10 @@ class MpyTool():
466
489
  parts.append(f"speedup {speedup:.1f}x")
467
490
  summary = " ".join(parts)
468
491
  if self._skipped_files > 0:
469
- file_info = f"{self._stats_transferred_files} transferred, {self._skipped_files} skipped"
492
+ file_info = f"{self._stats_transferred_files} transferred, {self._skipped_files} unchanged"
470
493
  else:
471
494
  file_info = f"{total_files} files"
472
- self.verbose(f" {summary} ({file_info})", color='green')
495
+ self.verbose(f" {summary} ({file_info})", color='green')
473
496
 
474
497
  @classmethod
475
498
  def print_tree(cls, tree, prefix='', print_size=True, first=True, last=True):
@@ -518,18 +541,26 @@ class MpyTool():
518
541
  """Upload file data to device with stats tracking and progress display"""
519
542
  file_size = len(data)
520
543
  self._stats_total_bytes += file_size
544
+ if show_progress and self._verbose >= 1:
545
+ self._progress_current_file += 1
546
+ self._set_progress_info(src_path, dst_path, False, True)
547
+ # Show CHCK status during checksum verification
548
+ if self._verbose >= 2:
549
+ line = self._format_line("CHCK", file_size)
550
+ else:
551
+ line = self._format_compact_progress("CHCK", file_size)
552
+ self.verbose(line, color='cyan', end='', overwrite=True)
521
553
  if not self._file_needs_update(data, dst_path):
522
554
  self._skipped_files += 1
523
- if show_progress and self._verbose >= 1:
524
- self._progress_current_file += 1
525
- self._set_progress_info(src_path, dst_path, False, True)
526
- self.verbose(self._format_skip_line(file_size), color='yellow')
555
+ if show_progress and self._verbose >= 2:
556
+ self.verbose(self._format_skip_line(file_size), color='yellow', overwrite=True)
557
+ elif show_progress and self._verbose >= 1:
558
+ # Erase CHCK line (next file's progress will overwrite)
559
+ self.verbose("", end='', overwrite=True)
527
560
  return False # skipped
528
561
  self._stats_transferred_bytes += file_size
529
562
  self._stats_transferred_files += 1
530
563
  if show_progress and self._verbose >= 1:
531
- self._progress_current_file += 1
532
- self._set_progress_info(src_path, dst_path, False, True)
533
564
  encodings, wire = self._mpy.put(data, dst_path, self._progress_callback, self._compress)
534
565
  self._stats_wire_bytes += wire
535
566
  self._progress_complete(file_size, encodings)
@@ -556,25 +587,25 @@ class MpyTool():
556
587
  self.verbose(f"PUT DIR: {src_path} -> {dst_path}", 2)
557
588
  created_dirs = set()
558
589
  for path, dirs, files in _os.walk(src_path, topdown=True):
559
- dirs[:] = [d for d in dirs if not self._is_excluded(d)]
560
- files = [f for f in files if not self._is_excluded(f)]
590
+ dirs[:] = sorted(d for d in dirs if not self._is_excluded(d))
591
+ files = sorted(f for f in files if not self._is_excluded(f))
561
592
  if not files:
562
593
  continue
563
- rel_path = _os.path.relpath(path, src_path)
564
- rel_path = _os.path.join(dst_path, '' if rel_path == '.' else rel_path)
565
- if rel_path and rel_path not in created_dirs:
566
- self.verbose(f'MKDIR: {rel_path}', 2)
567
- self._mpy.mkdir(rel_path)
568
- created_dirs.add(rel_path)
594
+ rel_path = _os.path.relpath(path, src_path).replace(_os.sep, '/')
595
+ remote_dir = _join_remote_path(dst_path, '' if rel_path == '.' else rel_path)
596
+ if remote_dir and remote_dir not in created_dirs:
597
+ self.verbose(f'MKDIR: {remote_dir}', 2)
598
+ self._mpy.mkdir(remote_dir)
599
+ created_dirs.add(remote_dir)
569
600
  for file_name in files:
570
601
  spath = _os.path.join(path, file_name)
571
602
  with open(spath, 'rb') as f:
572
- self._upload_file(f.read(), spath, _os.path.join(rel_path, file_name), show_progress)
603
+ self._upload_file(f.read(), spath, _join_remote_path(remote_dir, file_name), show_progress)
573
604
 
574
605
  def _put_file(self, src_path, dst_path, show_progress=True):
575
606
  basename = _os.path.basename(src_path)
576
607
  if basename and not _os.path.basename(dst_path):
577
- dst_path = _os.path.join(dst_path, basename)
608
+ dst_path = _join_remote_path(dst_path, basename)
578
609
  self.verbose(f"PUT FILE: {src_path} -> {dst_path}", 2)
579
610
  with open(src_path, 'rb') as f:
580
611
  data = f.read()
@@ -597,7 +628,7 @@ class MpyTool():
597
628
  self._progress_current_file += 1
598
629
  self._set_progress_info(src_path, dst_path, True, False)
599
630
  data = self._mpy.get(src_path, self._progress_callback)
600
- self._progress_complete(len(data))
631
+ self._progress_complete(len(data), None)
601
632
  else:
602
633
  data = self._mpy.get(src_path)
603
634
  file_size = len(data)
@@ -1396,7 +1427,7 @@ Commands (: prefix = device path, :/ = root, : = CWD):
1396
1427
  --boot enter bootloader (machine.bootloader)
1397
1428
  --dtr-boot bootloader via DTR/RTS (ESP32)
1398
1429
  monitor print device output (Ctrl+C to stop)
1399
- repl interactive REPL [Unix only]
1430
+ repl interactive REPL
1400
1431
  exec {code} execute Python code
1401
1432
  info show device information
1402
1433
  flash show flash/partitions info
@@ -0,0 +1,80 @@
1
+ """MicroPython tool: terminal connection
2
+
3
+ Base class with common terminal functionality.
4
+ Platform-specific implementations in terminal_unix.py and terminal_win.py.
5
+ """
6
+
7
+ import sys as _sys
8
+
9
+ AVAILABLE = False
10
+
11
+
12
+ class TerminalBase:
13
+ """Common terminal functionality for interactive REPL"""
14
+
15
+ def __init__(self, conn, log):
16
+ self._log = log
17
+ self._conn = conn
18
+ self._running = None
19
+
20
+ def _setup(self):
21
+ """Set terminal to raw mode (platform-specific)"""
22
+ raise NotImplementedError
23
+
24
+ def _restore(self):
25
+ """Restore original terminal mode (platform-specific)"""
26
+ raise NotImplementedError
27
+
28
+ def read(self):
29
+ """Read input from keyboard (platform-specific)"""
30
+ raise NotImplementedError
31
+
32
+ def _loop(self):
33
+ """Main event loop (platform-specific)"""
34
+ raise NotImplementedError
35
+
36
+ def write(self, buf):
37
+ _sys.stdout.buffer.raw.write(buf)
38
+
39
+ def _read_event_terminal(self):
40
+ data = self.read()
41
+ self._log.info('from terminal: %s', data)
42
+ if 0x1d in data: # CTRL + ]
43
+ self._running = False
44
+ return
45
+ if data:
46
+ self._conn.write(data)
47
+
48
+ def _read_event_device(self):
49
+ data = self._conn.read()
50
+ self._log.info('from device: %s', data)
51
+ if data:
52
+ self.write(data)
53
+
54
+ def _flush_device(self):
55
+ data = self._conn.flush()
56
+ if data:
57
+ self.write(data)
58
+
59
+ def run(self):
60
+ self._setup()
61
+ self._running = True
62
+ try:
63
+ self._flush_device()
64
+ self._loop()
65
+ except OSError as err:
66
+ if self._log:
67
+ self._log.error(err)
68
+ self._restore()
69
+ self.write(b'\r\n')
70
+
71
+
72
+ try:
73
+ from mpytool.terminal_unix import Terminal
74
+ AVAILABLE = True
75
+ except ImportError:
76
+ try:
77
+ from mpytool.terminal_win import Terminal
78
+ AVAILABLE = True
79
+ except ImportError:
80
+ pass
@@ -0,0 +1,45 @@
1
+ """MicroPython tool: Unix terminal (tty/termios/select)"""
2
+
3
+ import sys as _sys
4
+ import select as _select
5
+ import tty as _tty
6
+ import termios as _termios
7
+
8
+ from mpytool.terminal import TerminalBase
9
+
10
+
11
+ class Terminal(TerminalBase):
12
+
13
+ def __init__(self, conn, log):
14
+ super().__init__(conn, log)
15
+ self._stdin_fd = _sys.stdin.fileno()
16
+ self._orig_attr = _termios.tcgetattr(self._stdin_fd)
17
+
18
+ def __del__(self):
19
+ if self._orig_attr:
20
+ _termios.tcsetattr(
21
+ self._stdin_fd, _termios.TCSANOW, self._orig_attr)
22
+
23
+ def _setup(self):
24
+ _tty.setraw(self._stdin_fd)
25
+
26
+ def _restore(self):
27
+ if self._orig_attr:
28
+ _termios.tcsetattr(
29
+ self._stdin_fd, _termios.TCSANOW, self._orig_attr)
30
+ self._orig_attr = None
31
+
32
+ def read(self):
33
+ return _sys.stdin.buffer.raw.read(1)
34
+
35
+ def _loop(self):
36
+ select_fds = [self._stdin_fd, self._conn.fd]
37
+ self._log.info("select: %s", select_fds)
38
+ while self._running:
39
+ ret = _select.select(select_fds, [], [], 1)
40
+ self._log.info("selected: %s", ret)
41
+ if ret[0]:
42
+ if self._stdin_fd in ret[0]:
43
+ self._read_event_terminal()
44
+ if self._conn.fd in ret[0]:
45
+ self._read_event_device()
@@ -0,0 +1,88 @@
1
+ """MicroPython tool: Windows terminal (msvcrt/ctypes)"""
2
+
3
+ import sys as _sys
4
+ import time as _time
5
+ import ctypes as _ctypes
6
+ import msvcrt as _msvcrt
7
+
8
+ from mpytool.terminal import TerminalBase
9
+
10
+ # kernel32 console mode constants
11
+ _STD_INPUT_HANDLE = -10
12
+ _STD_OUTPUT_HANDLE = -11
13
+ _ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200
14
+ _ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
15
+
16
+ _kernel32 = _ctypes.windll.kernel32
17
+
18
+ # Windows scan code to ANSI escape sequence mapping
19
+ # msvcrt.getch() returns 0x00 or 0xE0 prefix + scan code for special keys
20
+ _SCAN_TO_ANSI = {
21
+ 72: b'\x1b[A', # Up
22
+ 80: b'\x1b[B', # Down
23
+ 77: b'\x1b[C', # Right
24
+ 75: b'\x1b[D', # Left
25
+ 71: b'\x1b[H', # Home
26
+ 79: b'\x1b[F', # End
27
+ 83: b'\x1b[3~', # Delete
28
+ 82: b'\x1b[2~', # Insert
29
+ 73: b'\x1b[5~', # Page Up
30
+ 81: b'\x1b[6~', # Page Down
31
+ }
32
+
33
+
34
+ class Terminal(TerminalBase):
35
+
36
+ def __init__(self, conn, log):
37
+ super().__init__(conn, log)
38
+ self._stdin_h = _kernel32.GetStdHandle(_STD_INPUT_HANDLE)
39
+ self._stdout_h = _kernel32.GetStdHandle(_STD_OUTPUT_HANDLE)
40
+ self._orig_in_mode = _ctypes.c_uint32()
41
+ self._orig_out_mode = _ctypes.c_uint32()
42
+ _kernel32.GetConsoleMode(
43
+ self._stdin_h, _ctypes.byref(self._orig_in_mode))
44
+ _kernel32.GetConsoleMode(
45
+ self._stdout_h, _ctypes.byref(self._orig_out_mode))
46
+
47
+ def __del__(self):
48
+ self._restore()
49
+
50
+ def _setup(self):
51
+ # Input: disable line input, echo, processed input
52
+ # enable virtual terminal input (Win10+)
53
+ _kernel32.SetConsoleMode(
54
+ self._stdin_h, _ENABLE_VIRTUAL_TERMINAL_INPUT)
55
+ # Output: enable ANSI escape processing
56
+ out_mode = self._orig_out_mode.value | _ENABLE_VIRTUAL_TERMINAL_PROCESSING
57
+ _kernel32.SetConsoleMode(self._stdout_h, out_mode)
58
+
59
+ def _restore(self):
60
+ if self._orig_in_mode.value:
61
+ _kernel32.SetConsoleMode(self._stdin_h, self._orig_in_mode)
62
+ self._orig_in_mode = _ctypes.c_uint32()
63
+ if self._orig_out_mode.value:
64
+ _kernel32.SetConsoleMode(self._stdout_h, self._orig_out_mode)
65
+ self._orig_out_mode = _ctypes.c_uint32()
66
+
67
+ def write(self, buf):
68
+ _sys.stdout.buffer.raw.write(buf)
69
+ _sys.stdout.buffer.raw.flush()
70
+
71
+ def read(self):
72
+ byte = _msvcrt.getch()
73
+ if byte in (b'\x00', b'\xe0'):
74
+ scan = _msvcrt.getch()
75
+ return _SCAN_TO_ANSI.get(scan[0], b'')
76
+ return byte
77
+
78
+ def _loop(self):
79
+ while self._running:
80
+ activity = False
81
+ if _msvcrt.kbhit():
82
+ self._read_event_terminal()
83
+ activity = True
84
+ if self._conn._has_data(0):
85
+ self._read_event_device()
86
+ activity = True
87
+ if not activity:
88
+ _time.sleep(0.002)
@@ -2,6 +2,8 @@
2
2
 
3
3
  import sys
4
4
 
5
+ from serial.tools.list_ports import comports as _comports
6
+
5
7
 
6
8
  def is_remote_path(path: str) -> bool:
7
9
  """Check if path is remote (starts with :)"""
@@ -74,7 +76,9 @@ def detect_serial_ports() -> list[str]:
74
76
  "/dev/ttyACM*",
75
77
  "/dev/ttyUSB*",
76
78
  ]
77
- # Windows not yet supported
79
+ elif sys.platform == "win32":
80
+ return sorted(
81
+ p.device for p in _comports() if p.vid is not None)
78
82
 
79
83
  ports = []
80
84
  for pattern in patterns:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mpytool
3
- Version: 2.2.0
3
+ Version: 2.2.2
4
4
  Summary: MPY tool - manage files on devices running MicroPython
5
5
  Author-email: Pavel Revak <pavel.revak@gmail.com>
6
6
  License-Expression: MIT
@@ -446,7 +446,15 @@ Working only with MicroPython boards, not with CircuitPython
446
446
 
447
447
  - Linux
448
448
  - MacOS
449
- - Windows (limited support - REPL mode is disabled)
449
+ - Windows
450
+
451
+ ### Windows notes
452
+
453
+ All commands work on Windows including `repl`.
454
+
455
+ **CMD.EXE**: ANSI colors are disabled, progress indicator works. Log messages use text prefixes (`E:`, `W:`, `I:`, `D:`).
456
+
457
+ **Git Bash**: Full color support (sets `TERM` environment variable).
450
458
 
451
459
  ## Credits
452
460
 
@@ -10,6 +10,8 @@ mpytool/mpy.py
10
10
  mpytool/mpy_comm.py
11
11
  mpytool/mpytool.py
12
12
  mpytool/terminal.py
13
+ mpytool/terminal_unix.py
14
+ mpytool/terminal_win.py
13
15
  mpytool/utils.py
14
16
  mpytool.egg-info/PKG-INFO
15
17
  mpytool.egg-info/SOURCES.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mpytool"
7
- version = "2.2.0"
7
+ version = "2.2.2"
8
8
  description = "MPY tool - manage files on devices running MicroPython"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -37,6 +37,37 @@ class TestCmdError(unittest.TestCase):
37
37
  def test_is_mpy_error(self):
38
38
  self.assertTrue(issubclass(CmdError, MpyError))
39
39
 
40
+ def test_friendly_oserror_no_space(self):
41
+ err = CmdError("f.write(b'data')", b"", b"Traceback (most recent call last):\n File \"<stdin>\", line 1, in <module>\nOSError: 28")
42
+ msg = str(err)
43
+ self.assertIn("No space left on device", msg)
44
+ self.assertIn("errno 28", msg)
45
+ self.assertNotIn("Traceback", msg)
46
+
47
+ def test_friendly_oserror_enoent(self):
48
+ err = CmdError("open('x')", b"", b"OSError: 2")
49
+ msg = str(err)
50
+ self.assertIn("No such file or directory", msg)
51
+ self.assertIn("errno 2", msg)
52
+
53
+ def test_friendly_oserror_read_only(self):
54
+ err = CmdError("f.write(b'')", b"", b"OSError: 30")
55
+ msg = str(err)
56
+ self.assertIn("Read-only filesystem", msg)
57
+
58
+ def test_unknown_oserror_shows_full(self):
59
+ err = CmdError("cmd", b"", b"OSError: 999")
60
+ msg = str(err)
61
+ self.assertIn("cmd", msg)
62
+ self.assertIn("OSError: 999", msg)
63
+
64
+ def test_non_oserror_shows_full(self):
65
+ err = CmdError("cmd", b"result", b"ValueError: bad")
66
+ msg = str(err)
67
+ self.assertIn("cmd", msg)
68
+ self.assertIn("result", msg)
69
+ self.assertIn("ValueError: bad", msg)
70
+
40
71
 
41
72
  class TestPathErrors(unittest.TestCase):
42
73
  def test_path_not_found(self):
@@ -31,6 +31,7 @@ class TestDeviceConnection(unittest.TestCase):
31
31
  @classmethod
32
32
  def tearDownClass(cls):
33
33
  cls.mpy.comm.exit_raw_repl()
34
+ cls.conn.close()
34
35
 
35
36
  def test_connection_established(self):
36
37
  """Test that connection is established"""
@@ -64,6 +65,7 @@ class TestFileOperations(unittest.TestCase):
64
65
  except Exception:
65
66
  pass
66
67
  cls.mpy.comm.exit_raw_repl()
68
+ cls.conn.close()
67
69
 
68
70
  def test_01_mkdir(self):
69
71
  """Test creating directory"""
@@ -143,6 +145,7 @@ class TestExec(unittest.TestCase):
143
145
  @classmethod
144
146
  def tearDownClass(cls):
145
147
  cls.mpy.comm.exit_raw_repl()
148
+ cls.conn.close()
146
149
 
147
150
  def test_exec_simple(self):
148
151
  """Test simple code execution"""
@@ -188,7 +191,7 @@ class TestReplRecovery(unittest.TestCase):
188
191
  # Close connection WITHOUT exiting raw REPL
189
192
  # This simulates a crash or unexpected disconnect
190
193
  del mpy1
191
- del conn1
194
+ conn1.close()
192
195
 
193
196
  # Second connection - device is still in raw REPL mode
194
197
  conn2 = ConnSerial(port=DEVICE_PORT, baudrate=115200)
@@ -201,6 +204,7 @@ class TestReplRecovery(unittest.TestCase):
201
204
  self.assertEqual(result, 2)
202
205
  finally:
203
206
  mpy2.comm.exit_raw_repl()
207
+ conn2.close()
204
208
 
205
209
 
206
210
  @requires_device
@@ -216,6 +220,7 @@ class TestDeviceInfo(unittest.TestCase):
216
220
  @classmethod
217
221
  def tearDownClass(cls):
218
222
  cls.mpy.comm.exit_raw_repl()
223
+ cls.conn.close()
219
224
 
220
225
  def test_get_platform(self):
221
226
  """Test getting platform info"""
@@ -271,6 +276,7 @@ class TestCwdOperations(unittest.TestCase):
271
276
  except Exception:
272
277
  pass
273
278
  cls.mpy.comm.exit_raw_repl()
279
+ cls.conn.close()
274
280
 
275
281
  def setUp(self):
276
282
  # Always start from root
@@ -420,6 +426,7 @@ class TestCpCommand(unittest.TestCase):
420
426
  if os.path.exists(cls.LOCAL_DIR):
421
427
  shutil.rmtree(cls.LOCAL_DIR)
422
428
  cls.mpy.comm.exit_raw_repl()
429
+ cls.conn.close()
423
430
 
424
431
  def test_01_upload_file(self):
425
432
  """Test cp local file to remote"""
@@ -524,6 +531,7 @@ class TestMvCommand(unittest.TestCase):
524
531
  except Exception:
525
532
  pass
526
533
  cls.mpy.comm.exit_raw_repl()
534
+ cls.conn.close()
527
535
 
528
536
  def test_01_rename_file(self):
529
537
  """Test mv rename file"""
@@ -578,6 +586,7 @@ class TestDeleteCommand(unittest.TestCase):
578
586
  except Exception:
579
587
  pass
580
588
  cls.mpy.comm.exit_raw_repl()
589
+ cls.conn.close()
581
590
 
582
591
  def test_01_delete_file(self):
583
592
  """Test rm single file"""
@@ -649,6 +658,7 @@ class TestSkipUnchangedFiles(unittest.TestCase):
649
658
  except Exception:
650
659
  pass
651
660
  cls.mpy.comm.exit_raw_repl()
661
+ cls.conn.close()
652
662
 
653
663
  def test_01_hashfile(self):
654
664
  """Test hashfile method returns correct SHA256"""
@@ -712,6 +722,7 @@ class TestEncodingAndCompression(unittest.TestCase):
712
722
  except Exception:
713
723
  pass
714
724
  cls.mpy.comm.exit_raw_repl()
725
+ cls.conn.close()
715
726
 
716
727
  def test_01_text_upload_uses_raw(self):
717
728
  """Test that text files use raw encoding"""
@@ -792,6 +803,7 @@ class TestCpWithFlags(unittest.TestCase):
792
803
  except Exception:
793
804
  pass
794
805
  cls.mpy.comm.exit_raw_repl()
806
+ cls.conn.close()
795
807
 
796
808
  def test_01_cp_with_combined_flags(self):
797
809
  """Test cp -fz with combined flags"""
@@ -845,6 +857,7 @@ class TestSleepCommand(unittest.TestCase):
845
857
  @classmethod
846
858
  def tearDownClass(cls):
847
859
  cls.mpy.comm.exit_raw_repl()
860
+ cls.conn.close()
848
861
 
849
862
  def test_sleep_delays_execution(self):
850
863
  """Test that sleep actually delays execution"""
@@ -886,6 +899,7 @@ class TestSpecialCharacterFilenames(unittest.TestCase):
886
899
  except Exception:
887
900
  pass
888
901
  cls.mpy.comm.exit_raw_repl()
902
+ cls.conn.close()
889
903
 
890
904
  def test_01_filename_with_equals_sign(self):
891
905
  """Test file with equals sign in name (mpremote #18658)"""
@@ -979,6 +993,7 @@ class TestUnicodeFilenames(unittest.TestCase):
979
993
  except Exception:
980
994
  pass
981
995
  cls.mpy.comm.exit_raw_repl()
996
+ cls.conn.close()
982
997
 
983
998
  def test_01_czech_filename(self):
984
999
  """Test file with Czech characters in name"""
@@ -1081,6 +1096,7 @@ class TestCpSpecialFilenames(unittest.TestCase):
1081
1096
  if os.path.exists(cls.LOCAL_DIR):
1082
1097
  shutil.rmtree(cls.LOCAL_DIR)
1083
1098
  cls.mpy.comm.exit_raw_repl()
1099
+ cls.conn.close()
1084
1100
 
1085
1101
  def test_01_upload_file_with_equals(self):
1086
1102
  """Test cp upload file with equals sign (mpremote #18658)"""
@@ -1182,6 +1198,7 @@ class TestErrorMessages(unittest.TestCase):
1182
1198
  except Exception:
1183
1199
  pass
1184
1200
  cls.mpy.comm.exit_raw_repl()
1201
+ cls.conn.close()
1185
1202
 
1186
1203
  def test_01_get_nonexistent_file(self):
1187
1204
  """Test get on nonexistent file raises FileNotFound"""
@@ -1236,6 +1253,7 @@ class TestLargeFileTransfer(unittest.TestCase):
1236
1253
  except Exception:
1237
1254
  pass
1238
1255
  cls.mpy.comm.exit_raw_repl()
1256
+ cls.conn.close()
1239
1257
 
1240
1258
  def test_01_upload_10kb_file(self):
1241
1259
  """Test upload of 10KB file"""
@@ -1285,6 +1303,7 @@ class TestPartitions(unittest.TestCase):
1285
1303
  @classmethod
1286
1304
  def tearDownClass(cls):
1287
1305
  cls.mpy.comm.exit_raw_repl()
1306
+ cls.conn.close()
1288
1307
 
1289
1308
  def test_01_partitions_list(self):
1290
1309
  """Test listing partitions on ESP32"""
@@ -1338,6 +1357,7 @@ class TestFlashRP2(unittest.TestCase):
1338
1357
  @classmethod
1339
1358
  def tearDownClass(cls):
1340
1359
  cls.mpy.comm.exit_raw_repl()
1360
+ cls.conn.close()
1341
1361
 
1342
1362
  def test_01_flash_info(self):
1343
1363
  """Test getting flash info on RP2"""
@@ -1392,6 +1412,7 @@ class TestRawPasteMode(unittest.TestCase):
1392
1412
  @classmethod
1393
1413
  def tearDownClass(cls):
1394
1414
  cls.comm.exit_raw_repl()
1415
+ cls.conn.close()
1395
1416
 
1396
1417
  def test_01_raw_paste_simple(self):
1397
1418
  """Test simple code execution via raw-paste"""
@@ -149,11 +149,13 @@ class TestDetectSerialPorts(unittest.TestCase):
149
149
  self.assertIn("/dev/ttyUSB0", ports)
150
150
 
151
151
  @patch("sys.platform", "win32")
152
- @patch("glob.glob")
153
- def test_windows_not_supported(self, mock_glob):
152
+ @patch("mpytool.utils._comports")
153
+ def test_windows_comports(self, mock_comports):
154
+ mock_port1 = type('PortInfo', (), {'device': 'COM3', 'vid': 0x2E8A})()
155
+ mock_port2 = type('PortInfo', (), {'device': 'COM1', 'vid': None})()
156
+ mock_comports.return_value = [mock_port1, mock_port2]
154
157
  ports = detect_serial_ports()
155
- self.assertEqual(ports, [])
156
- mock_glob.assert_not_called()
158
+ self.assertEqual(ports, ['COM3'])
157
159
 
158
160
  @patch("sys.platform", "linux")
159
161
  @patch("glob.glob")
@@ -1,76 +0,0 @@
1
- """MicroPython tool: terminal connection"""
2
-
3
- AVAILABLE = False
4
-
5
- try:
6
- import sys as _sys
7
- import select as _select
8
- import tty as _tty
9
- import termios as _termios
10
-
11
- AVAILABLE = True
12
-
13
- class Terminal:
14
- def __init__(self, conn, log):
15
- self._log = log
16
- self._conn = conn
17
- self._stdin_fd = _sys.stdin.fileno()
18
- self._last_attr = _termios.tcgetattr(self._stdin_fd)
19
- self._running = None
20
-
21
- def __del__(self):
22
- if self._last_attr:
23
- _termios.tcsetattr(self._stdin_fd, _termios.TCSANOW, self._last_attr)
24
-
25
- def read(self):
26
- return _sys.stdin.buffer.raw.read(1)
27
-
28
- def write(self, buf):
29
- _sys.stdout.buffer.raw.write(buf)
30
-
31
- def _read_event_terminal(self):
32
- data = self.read()
33
- self._log.info('from terminal: %s', data)
34
- if 0x1d in data: # CTRL + ]
35
- self._running = False
36
- self._conn.write(data)
37
-
38
- def _read_event_device(self):
39
- data = self._conn.read()
40
- self._log.info('from device: %s', data)
41
- if data:
42
- self.write(data)
43
-
44
- def _flush_device(self):
45
- data = self._conn.flush()
46
- if data:
47
- self.write(data)
48
-
49
- def _read_event(self, event):
50
- if self._stdin_fd in event:
51
- self._read_event_terminal()
52
- if self._conn.fd in event:
53
- self._read_event_device()
54
-
55
- def run(self):
56
- _tty.setraw(self._stdin_fd)
57
- self._running = True
58
- try:
59
- self._flush_device()
60
- select_fds = [self._stdin_fd, self._conn.fd, ]
61
- self._log.info("select: %s", select_fds)
62
- while self._running:
63
- ret = _select.select(select_fds, [], [], 1)
64
- self._log.info("selected: %s", ret)
65
- if ret[0]:
66
- self._read_event(ret[0])
67
- except OSError as err:
68
- if self._log:
69
- self._log.error(err)
70
- _termios.tcsetattr(self._stdin_fd, _termios.TCSANOW, self._last_attr)
71
- self._last_attr = None
72
- self.write(b'\r\n')
73
-
74
-
75
- except ImportError:
76
- pass
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes