mpytool 2.3.1__tar.gz → 2.3.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 (36) hide show
  1. {mpytool-2.3.1 → mpytool-2.3.2}/PKG-INFO +33 -1
  2. {mpytool-2.3.1 → mpytool-2.3.2}/README.md +32 -0
  3. {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/conn_serial.py +49 -27
  4. {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/mpytool.py +38 -33
  5. mpytool-2.3.2/mpytool/utils.py +136 -0
  6. {mpytool-2.3.1 → mpytool-2.3.2}/mpytool.egg-info/PKG-INFO +33 -1
  7. {mpytool-2.3.1 → mpytool-2.3.2}/mpytool.egg-info/SOURCES.txt +1 -0
  8. {mpytool-2.3.1 → mpytool-2.3.2}/pyproject.toml +1 -1
  9. mpytool-2.3.2/tests/test_cli.py +634 -0
  10. {mpytool-2.3.1 → mpytool-2.3.2}/tests/test_integration.py +54 -26
  11. {mpytool-2.3.1 → mpytool-2.3.2}/tests/test_utils.py +81 -36
  12. mpytool-2.3.1/mpytool/utils.py +0 -100
  13. {mpytool-2.3.1 → mpytool-2.3.2}/LICENSE +0 -0
  14. {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/__init__.py +0 -0
  15. {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/cmd_cp.py +0 -0
  16. {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/conn.py +0 -0
  17. {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/conn_socket.py +0 -0
  18. {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/logger.py +0 -0
  19. {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/mount.py +0 -0
  20. {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/mpy.py +0 -0
  21. {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/mpy_comm.py +0 -0
  22. {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/mpy_cross.py +0 -0
  23. {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/speedtest.py +0 -0
  24. {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/terminal.py +0 -0
  25. {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/terminal_unix.py +0 -0
  26. {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/terminal_win.py +0 -0
  27. {mpytool-2.3.1 → mpytool-2.3.2}/mpytool.egg-info/dependency_links.txt +0 -0
  28. {mpytool-2.3.1 → mpytool-2.3.2}/mpytool.egg-info/entry_points.txt +0 -0
  29. {mpytool-2.3.1 → mpytool-2.3.2}/mpytool.egg-info/requires.txt +0 -0
  30. {mpytool-2.3.1 → mpytool-2.3.2}/mpytool.egg-info/top_level.txt +0 -0
  31. {mpytool-2.3.1 → mpytool-2.3.2}/setup.cfg +0 -0
  32. {mpytool-2.3.1 → mpytool-2.3.2}/tests/test_cmd_cp.py +0 -0
  33. {mpytool-2.3.1 → mpytool-2.3.2}/tests/test_errors.py +0 -0
  34. {mpytool-2.3.1 → mpytool-2.3.2}/tests/test_mount.py +0 -0
  35. {mpytool-2.3.1 → mpytool-2.3.2}/tests/test_mpy.py +0 -0
  36. {mpytool-2.3.1 → mpytool-2.3.2}/tests/test_mpytool.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mpytool
3
- Version: 2.3.1
3
+ Version: 2.3.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
@@ -692,6 +692,38 @@ exec bash
692
692
  - Support for `--` command separator
693
693
  - Works with both relative and absolute paths
694
694
 
695
+ ## Development
696
+
697
+ ### Running tests
698
+
699
+ ```bash
700
+ # Unit tests (no device needed)
701
+ python -m unittest discover tests/ -v
702
+
703
+ # Read-only integration tests (safe on production device)
704
+ MPYTOOL_TEST_PORT=/dev/ttyACM0 python -m unittest tests.test_integration -v
705
+
706
+ # All integration tests (dedicated test device)
707
+ MPYTOOL_TEST_PORT_RW=/dev/ttyACM0 python -m unittest tests.test_integration -v
708
+
709
+ # CLI tests (no device needed for basic tests)
710
+ python -m unittest tests.test_cli -v
711
+
712
+ # All CLI tests with device
713
+ MPYTOOL_TEST_PORT_RW=/dev/ttyACM0 python -m unittest tests.test_cli -v
714
+ ```
715
+
716
+ Test categories:
717
+ - **Unit tests** (`test_*.py` except `test_integration.py`, `test_cli.py`) - no device needed
718
+ - **Read-only tests** (`MPYTOOL_TEST_PORT`) - safe on any device, no filesystem changes
719
+ - **All tests** (`MPYTOOL_TEST_PORT_RW`) - includes write and flash operations, use dedicated test device
720
+
721
+ ### Install from source
722
+
723
+ ```bash
724
+ pip install -e .
725
+ ```
726
+
695
727
  ## Requirements
696
728
 
697
729
  Working only with MicroPython boards, not with CircuitPython
@@ -678,6 +678,38 @@ exec bash
678
678
  - Support for `--` command separator
679
679
  - Works with both relative and absolute paths
680
680
 
681
+ ## Development
682
+
683
+ ### Running tests
684
+
685
+ ```bash
686
+ # Unit tests (no device needed)
687
+ python -m unittest discover tests/ -v
688
+
689
+ # Read-only integration tests (safe on production device)
690
+ MPYTOOL_TEST_PORT=/dev/ttyACM0 python -m unittest tests.test_integration -v
691
+
692
+ # All integration tests (dedicated test device)
693
+ MPYTOOL_TEST_PORT_RW=/dev/ttyACM0 python -m unittest tests.test_integration -v
694
+
695
+ # CLI tests (no device needed for basic tests)
696
+ python -m unittest tests.test_cli -v
697
+
698
+ # All CLI tests with device
699
+ MPYTOOL_TEST_PORT_RW=/dev/ttyACM0 python -m unittest tests.test_cli -v
700
+ ```
701
+
702
+ Test categories:
703
+ - **Unit tests** (`test_*.py` except `test_integration.py`, `test_cli.py`) - no device needed
704
+ - **Read-only tests** (`MPYTOOL_TEST_PORT`) - safe on any device, no filesystem changes
705
+ - **All tests** (`MPYTOOL_TEST_PORT_RW`) - includes write and flash operations, use dedicated test device
706
+
707
+ ### Install from source
708
+
709
+ ```bash
710
+ pip install -e .
711
+ ```
712
+
681
713
  ## Requirements
682
714
 
683
715
  Working only with MicroPython boards, not with CircuitPython
@@ -3,6 +3,7 @@
3
3
  import time as _time
4
4
  import serial as _serial
5
5
  import mpytool.conn as _conn
6
+ import mpytool.utils as _utils
6
7
 
7
8
 
8
9
  class ConnSerial(_conn.Conn):
@@ -11,6 +12,7 @@ class ConnSerial(_conn.Conn):
11
12
  def __init__(self, log=None, **serial_config):
12
13
  super().__init__(log)
13
14
  self._serial_config = serial_config
15
+ self._port_info = _utils.get_port_info(serial_config.get('port', ''))
14
16
  try:
15
17
  self._serial = _serial.Serial(**serial_config)
16
18
  except _serial.serialutil.SerialException as err:
@@ -20,6 +22,17 @@ class ConnSerial(_conn.Conn):
20
22
  # Windows: pyserial has no fd, select() works only with sockets
21
23
  if not hasattr(self._serial, 'fd'):
22
24
  self._has_data = self._has_data_polling
25
+ # Debug info about port type
26
+ if self._log:
27
+ port = serial_config.get('port', '')
28
+ if self._port_info and self._port_info.vid:
29
+ vid = self._port_info.vid
30
+ pid = self._port_info.pid
31
+ self._log.info(
32
+ "Connected to %s [%04X:%04X %s]",
33
+ port, vid, pid, self.port_type)
34
+ else:
35
+ self._log.info("Connected to %s [unknown serial port]", port)
23
36
 
24
37
  def close(self):
25
38
  if self._serial:
@@ -74,13 +87,38 @@ class ConnSerial(_conn.Conn):
74
87
  except OSError as err:
75
88
  raise _conn.ConnError(f"Connection lost: {err}") from err
76
89
 
77
- def _is_usb_cdc(self):
78
- """Check if this is a USB-CDC port (native USB on ESP32-S/C)"""
79
- port = self._serial.port or ''
80
- return 'ACM' in port or 'usbmodem' in port
90
+ @property
91
+ def vid(self):
92
+ """USB Vendor ID or None if not available"""
93
+ return self._port_info.vid if self._port_info else None
94
+
95
+ @property
96
+ def pid(self):
97
+ """USB Product ID or None if not available"""
98
+ return self._port_info.pid if self._port_info else None
99
+
100
+ @property
101
+ def port_type(self):
102
+ """Return port type string for debug info"""
103
+ if self._is_usb_uart():
104
+ return "USB-UART"
105
+ if self.vid in _utils._MICROPYTHON_VIDS:
106
+ return "USB-CDC"
107
+ if self.vid:
108
+ return "USB (unknown)"
109
+ return "unknown"
110
+
111
+ def _is_usb_uart(self):
112
+ """Check if this is a USB-UART bridge (CP210x, CH340, FTDI, etc.)"""
113
+ return self.vid in _utils._USB_UART_VIDS
81
114
 
82
115
  def hard_reset(self):
83
- """Hardware reset using DTR/RTS signals"""
116
+ """Hardware reset using DTR/RTS signals (USB-UART only)"""
117
+ if not self._is_usb_uart():
118
+ raise _conn.ConnError(
119
+ f"Hardware reset not supported on {self.port_type}")
120
+ if self._log:
121
+ self._log.info("hard_reset: using DTR/RTS")
84
122
  self._serial.setDTR(False) # GPIO0 high (normal boot)
85
123
  self._serial.setRTS(True) # Assert reset
86
124
  _time.sleep(0.1)
@@ -117,28 +155,12 @@ class ConnSerial(_conn.Conn):
117
155
  raise _conn.ConnError(f"Could not reconnect to {port} within {timeout}s")
118
156
 
119
157
  def reset_to_bootloader(self):
120
- """Reset into bootloader mode (auto-detects USB-CDC vs USB-UART)"""
121
- if self._is_usb_cdc():
122
- self._reset_to_bootloader_usb_jtag()
123
- else:
124
- self._reset_to_bootloader_classic()
125
-
126
- def _reset_to_bootloader_usb_jtag(self):
127
- """Bootloader reset for USB-JTAG-Serial (esptool sequence)"""
128
- self._serial.setRTS(False)
129
- self._serial.setDTR(False)
130
- _time.sleep(0.1)
131
- self._serial.setDTR(True)
132
- self._serial.setRTS(True)
133
- _time.sleep(0.1)
134
- self._serial.setDTR(False)
135
- self._serial.setRTS(True)
136
- _time.sleep(0.1)
137
- self._serial.setDTR(False)
138
- self._serial.setRTS(False)
139
-
140
- def _reset_to_bootloader_classic(self):
141
- """Bootloader reset for USB-UART (classic DTR/RTS circuit)"""
158
+ """Reset into bootloader mode (USB-UART only)"""
159
+ if not self._is_usb_uart():
160
+ raise _conn.ConnError(
161
+ f"Bootloader reset not supported on {self.port_type}")
162
+ if self._log:
163
+ self._log.info("reset_to_bootloader: using DTR/RTS")
142
164
  self._serial.setDTR(False) # GPIO0 high
143
165
  self._serial.setRTS(True) # Assert reset
144
166
  _time.sleep(0.1)
@@ -57,6 +57,13 @@ def _remote_basename(path):
57
57
  return path.rstrip('/').split('/')[-1]
58
58
 
59
59
 
60
+ def _normalize_path_arg(value):
61
+ """Argparse type for path arguments - normalize Windows backslashes"""
62
+ if _sys.platform == 'win32' and value.startswith(':'):
63
+ return ':' + value[1:].replace('\\', '/')
64
+ return value
65
+
66
+
60
67
  def _parse_device_path(path, cmd_name):
61
68
  """Parse device path with : prefix, raise ParamsError if missing
62
69
 
@@ -65,12 +72,17 @@ def _parse_device_path(path, cmd_name):
65
72
  cmd_name: command name for error message
66
73
 
67
74
  Returns:
68
- path without : prefix
75
+ path without : prefix (normalized on Windows)
69
76
  """
70
77
  if not path.startswith(':'):
71
78
  raise ParamsError(
72
79
  f'{cmd_name} requires device path (: prefix): {path}')
73
- return path[1:]
80
+ # Normalize backslashes on Windows (handled by argparse type for most
81
+ # commands, but _paths doesn't use argparse)
82
+ result = path[1:]
83
+ if _sys.platform == 'win32':
84
+ result = result.replace('\\', '/')
85
+ return result
74
86
 
75
87
 
76
88
  # Command argument subparsers
@@ -205,7 +217,6 @@ class MpyTool():
205
217
  f"Multiple serial ports found: {ports_str}. "
206
218
  "Use -p to specify one.")
207
219
  port = ports[0]
208
- self.verbose(f"Using {port}", level=2)
209
220
  if port:
210
221
  self._conn = _mpytool.ConnSerial(
211
222
  port=port, baudrate=self._baudrate, log=self._log)
@@ -727,32 +738,22 @@ class MpyTool():
727
738
  elif mode == 'rts':
728
739
  try:
729
740
  self.mpy.hard_reset()
730
- if reconnect:
731
- self.verbose(
732
- " reconnecting...", 1, color='yellow')
733
- _time.sleep(1.0) # Wait for device to boot
734
- self.mpy._conn.reconnect()
735
- self.verbose(" connected", 1, color='green')
736
- except NotImplementedError:
737
- raise _mpytool.MpyError(
738
- "Hardware reset not available (serial only)")
739
- except (_mpytool.ConnError, OSError) as err:
740
- self.verbose(
741
- f" reconnect failed: {err}", 1, color='red')
742
- raise _mpytool.ConnError(
743
- f"Reconnect failed: {err}")
741
+ except (NotImplementedError, _mpytool.ConnError) as err:
742
+ raise _mpytool.MpyError(f"Hardware reset failed: {err}")
743
+ # USB stays connected, just wait for device to boot
744
+ if reconnect:
745
+ _time.sleep(0.5)
744
746
  elif mode == 'boot':
745
747
  self.mpy.machine_bootloader()
746
748
  elif mode == 'dtr-boot':
747
749
  try:
748
750
  self.mpy.reset_to_bootloader()
749
- except NotImplementedError:
750
- raise _mpytool.MpyError(
751
- "DTR boot not available (serial only)")
751
+ except (NotImplementedError, _mpytool.ConnError) as err:
752
+ raise _mpytool.MpyError(f"Bootloader reset failed: {err}")
752
753
 
753
754
  @command('ls', 'List files and directories on device.')
754
755
  @argument('path', nargs='?', default=':', metavar='remote',
755
- help='device path (default: CWD)')
756
+ type=_normalize_path_arg, help='device path (default: CWD)')
756
757
  def _dispatch_ls(self, commands, is_last_group):
757
758
  args = _make_parser(self._dispatch_ls).parse_args(commands[:1])
758
759
  del commands[:1]
@@ -776,7 +777,7 @@ class MpyTool():
776
777
 
777
778
  @command('tree', 'Show directory tree on device.')
778
779
  @argument('path', nargs='?', default=':', metavar='remote',
779
- help='device path (default: CWD)')
780
+ type=_normalize_path_arg, help='device path (default: CWD)')
780
781
  def _dispatch_tree(self, commands, is_last_group):
781
782
  args = _make_parser(self._dispatch_tree).parse_args(commands[:1])
782
783
  del commands[:1]
@@ -790,7 +791,7 @@ class MpyTool():
790
791
 
791
792
  @command('cat', 'Print file content from device to stdout.')
792
793
  @argument('paths', nargs='+', metavar='remote',
793
- help='device path(s) to print')
794
+ type=_normalize_path_arg, help='device path(s) to print')
794
795
  def _dispatch_cat(self, commands, is_last_group):
795
796
  args = _make_parser(self._dispatch_cat).parse_args(commands)
796
797
  commands.clear()
@@ -802,7 +803,7 @@ class MpyTool():
802
803
 
803
804
  @command('mkdir', 'Create directory (with parents if needed).')
804
805
  @argument('paths', nargs='+', metavar='remote',
805
- help='device path(s) to create')
806
+ type=_normalize_path_arg, help='device path(s) to create')
806
807
  def _dispatch_mkdir(self, commands, is_last_group):
807
808
  args = _make_parser(self._dispatch_mkdir).parse_args(commands)
808
809
  commands.clear()
@@ -813,7 +814,7 @@ class MpyTool():
813
814
 
814
815
  @command('rm', 'Delete files/dirs. Use :path/ for contents only.')
815
816
  @argument('paths', nargs='+', metavar='remote',
816
- help='device path(s) to delete')
817
+ type=_normalize_path_arg, help='device path(s) to delete')
817
818
  def _dispatch_rm(self, commands, is_last_group):
818
819
  args = _make_parser(self._dispatch_rm).parse_args(commands)
819
820
  commands.clear()
@@ -828,7 +829,8 @@ class MpyTool():
828
829
  print(cwd)
829
830
 
830
831
  @command('cd', 'Change current working directory on device.')
831
- @argument('path', metavar='remote', help='device path')
832
+ @argument('path', metavar='remote', type=_normalize_path_arg,
833
+ help='device path')
832
834
  def _dispatch_cd(self, commands, is_last_group):
833
835
  args = _make_parser(self._dispatch_cd).parse_args([commands.pop(0)])
834
836
  path = _parse_device_path(args.path, 'cd')
@@ -844,7 +846,7 @@ class MpyTool():
844
846
  @option('-d', '--delete', action='store_const', const='delete',
845
847
  dest='mode', help='delete from sys.path')
846
848
  @argument('paths', nargs='*', metavar='remote',
847
- help='paths to add/remove (: prefix required)')
849
+ type=_normalize_path_arg, help='paths to add/remove (: prefix required)')
848
850
  def _dispatch_path(self, commands, is_last_group):
849
851
  args = _make_parser(self._dispatch_path).parse_args(commands)
850
852
  commands.clear()
@@ -984,7 +986,8 @@ class MpyTool():
984
986
 
985
987
  @command('edit', 'Edit file on device in local editor.')
986
988
  @option('--editor', metavar='CMD', help='editor command')
987
- @argument('path', metavar='remote', help='device file to edit (: prefix)')
989
+ @argument('path', metavar='remote', type=_normalize_path_arg,
990
+ help='device file to edit (: prefix)')
988
991
  def _dispatch_edit(self, commands, is_last_group):
989
992
  args = _make_parser(self._dispatch_edit).parse_args(commands)
990
993
  commands.clear()
@@ -1115,14 +1118,14 @@ class MpyTool():
1115
1118
  @option('-Z', '--no-compress', dest='no_compress', action='store_true',
1116
1119
  help='disable compression')
1117
1120
  @argument('paths', nargs='*', metavar='local_or_remote',
1118
- help='source(s) and dest (: prefix for device)')
1121
+ type=_normalize_path_arg, help='source(s) and dest (: prefix for device)')
1119
1122
  def _dispatch_cp(self, commands, is_last_group):
1120
1123
  self.cmd_cp(commands)
1121
1124
  commands.clear()
1122
1125
 
1123
1126
  @command('mv', 'Move or rename files on device.')
1124
1127
  @argument('paths', nargs='+', metavar='remote',
1125
- help='source(s) and destination (all with : prefix)')
1128
+ type=_normalize_path_arg, help='source(s) and destination (all with : prefix)')
1126
1129
  def _dispatch_mv(self, commands, is_last_group):
1127
1130
  args = _make_parser(self._dispatch_mv).parse_args(commands)
1128
1131
  commands.clear()
@@ -1177,7 +1180,7 @@ class MpyTool():
1177
1180
  help='compile .py to .mpy on-the-fly')
1178
1181
  @option('-w', '--writable', action='store_true', help='mount as writable')
1179
1182
  @argument('paths', nargs='*', metavar='local_and_remote',
1180
- help='local_dir [:mount_point] (default: /remote)')
1183
+ type=_normalize_path_arg, help='local_dir [:mount_point] (default: /remote)')
1181
1184
  def _dispatch_mount(self, commands, is_last_group):
1182
1185
  if not commands:
1183
1186
  self._list_mounts()
@@ -1219,7 +1222,7 @@ class MpyTool():
1219
1222
 
1220
1223
  @command('ln', 'Link local file/directory into mounted VFS.')
1221
1224
  @argument('paths', nargs='+', metavar='local_and_remote',
1222
- help='local source(s) and device destination')
1225
+ type=_normalize_path_arg, help='local source(s) and device destination')
1223
1226
  def _dispatch_ln(self, commands, is_last_group):
1224
1227
  args = _make_parser(self._dispatch_ln).parse_args(commands)
1225
1228
  commands.clear()
@@ -1525,7 +1528,7 @@ def main():
1525
1528
  log = SimpleColorLogger(args.debug + 1, verbose_level=args.verbose)
1526
1529
  if args.port and args.address:
1527
1530
  log.error("You can select only serial port or network address")
1528
- return
1531
+ _sys.exit(1)
1529
1532
  # Determine compression setting: None=auto, True=force, False=disable
1530
1533
  compress = None
1531
1534
  if args.no_compress:
@@ -1543,9 +1546,11 @@ def main():
1543
1546
  _run_commands(mpy_tool, command_groups, with_progress=with_progress)
1544
1547
  except (_mpytool.MpyError, _mpytool.ConnError, _mpytool.Timeout) as err:
1545
1548
  log.error(err)
1549
+ _sys.exit(1)
1546
1550
  except KeyboardInterrupt:
1547
1551
  # Clear partial progress line and show clean message
1548
1552
  log.verbose('Interrupted', level=0, overwrite=True)
1553
+ _sys.exit(130) # 128 + SIGINT(2)
1549
1554
 
1550
1555
 
1551
1556
  if __name__ == '__main__':
@@ -0,0 +1,136 @@
1
+ """Utility functions for mpytool"""
2
+
3
+ import sys as _sys
4
+
5
+ from serial.tools.list_ports import comports as _comports
6
+
7
+
8
+ # Known USB Vendor IDs for MicroPython devices
9
+ # Native USB-CDC (direct USB connection to microcontroller)
10
+ VID_RASPBERRY_PI = 0x2E8A # RP2040/RP2350 (Pico)
11
+ VID_ESPRESSIF = 0x303A # ESP32-S2/S3/C3/C6 USB-JTAG-Serial
12
+ VID_MICROPYTHON = 0xF055 # Official MicroPython (pyboard, STM32)
13
+
14
+ # USB-UART bridge chips (external USB-to-serial converter)
15
+ VID_SILICON_LABS = 0x10C4 # CP210x
16
+ VID_QINHENG = 0x1A86 # CH340/CH341
17
+ VID_FTDI = 0x0403 # FT232/FT2232
18
+ VID_PROLIFIC = 0x067B # PL2303
19
+
20
+ # Priority groups for port sorting (lower = higher priority)
21
+ _MICROPYTHON_VIDS = {VID_RASPBERRY_PI, VID_ESPRESSIF, VID_MICROPYTHON}
22
+ _USB_UART_VIDS = {VID_SILICON_LABS, VID_QINHENG, VID_FTDI, VID_PROLIFIC}
23
+
24
+
25
+ def is_remote_path(path: str) -> bool:
26
+ """Check if path is remote (starts with :)"""
27
+ return path.startswith(":")
28
+
29
+
30
+ def parse_remote_path(path: str) -> str:
31
+ """Parse remote path, strip leading :
32
+
33
+ Args:
34
+ path: path with : prefix
35
+
36
+ Returns:
37
+ path without : prefix (empty string for ':' means CWD)
38
+ """
39
+ if not is_remote_path(path):
40
+ raise ValueError(f"Not a remote path: {path}")
41
+ return path[1:]
42
+
43
+
44
+ def split_commands(args: list[str], separator: str = "--") -> list[list[str]]:
45
+ """Split command arguments by separator
46
+
47
+ Args:
48
+ args: list of arguments
49
+ separator: separator string (default: --)
50
+
51
+ Returns:
52
+ list of command groups
53
+
54
+ Example:
55
+ split_commands(["cp", "a", ":/", "--", "reset"])
56
+ => [["cp", "a", ":/"], ["reset"]]
57
+ """
58
+ if separator not in args:
59
+ return [args] if args else []
60
+
61
+ groups = []
62
+ current = []
63
+ for arg in args:
64
+ if arg == separator:
65
+ if current:
66
+ groups.append(current)
67
+ current = []
68
+ else:
69
+ current.append(arg)
70
+ if current:
71
+ groups.append(current)
72
+ return groups
73
+
74
+
75
+ def format_size(size):
76
+ """Format size in bytes to human readable format (like ls -h)"""
77
+ if size < 1024:
78
+ return f"{int(size)}B"
79
+ for unit in ('K', 'M', 'G', 'T'):
80
+ size /= 1024
81
+ if size < 10:
82
+ return f"{size:.2f}{unit}"
83
+ if size < 100:
84
+ return f"{size:.1f}{unit}"
85
+ if size < 1024 or unit == 'T':
86
+ return f"{size:.0f}{unit}"
87
+ return f"{size:.0f}T"
88
+
89
+
90
+ def _port_sort_key(port_info):
91
+ """Sort key for serial ports - prioritize known MicroPython VIDs"""
92
+ vid = port_info.vid
93
+ if vid is None:
94
+ return (3, port_info.device) # Unknown - lowest priority
95
+ if vid in _MICROPYTHON_VIDS:
96
+ return (0, port_info.device) # Native MicroPython USB - highest
97
+ if vid in _USB_UART_VIDS:
98
+ return (1, port_info.device) # USB-UART bridges - second
99
+ return (2, port_info.device) # Other USB devices - third
100
+
101
+
102
+ def detect_serial_ports() -> list[str]:
103
+ """Detect available serial ports for MicroPython devices
104
+
105
+ Returns:
106
+ list of port paths sorted by likelihood of being MicroPython device
107
+ (known MicroPython VIDs first, then USB-UART bridges, then others)
108
+ """
109
+ ports = []
110
+ for p in _comports():
111
+ # Skip non-USB devices (Bluetooth, debug console, etc.)
112
+ if p.vid is None:
113
+ continue
114
+ # macOS: prefer cu.* over tty.* (doesn't wait for DCD signal)
115
+ if _sys.platform == "darwin" and p.device.startswith("/dev/tty."):
116
+ continue
117
+ ports.append(p)
118
+ # Sort by priority (known MicroPython VIDs first)
119
+ ports.sort(key=_port_sort_key)
120
+ return [p.device for p in ports]
121
+
122
+
123
+ def get_port_info(port: str):
124
+ """Get USB info for a serial port
125
+
126
+ Args:
127
+ port: port path (e.g. /dev/ttyACM0, COM3)
128
+
129
+ Returns:
130
+ ListPortInfo object with vid, pid, manufacturer, product, etc.
131
+ or None if port not found
132
+ """
133
+ for p in _comports():
134
+ if p.device == port:
135
+ return p
136
+ return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mpytool
3
- Version: 2.3.1
3
+ Version: 2.3.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
@@ -692,6 +692,38 @@ exec bash
692
692
  - Support for `--` command separator
693
693
  - Works with both relative and absolute paths
694
694
 
695
+ ## Development
696
+
697
+ ### Running tests
698
+
699
+ ```bash
700
+ # Unit tests (no device needed)
701
+ python -m unittest discover tests/ -v
702
+
703
+ # Read-only integration tests (safe on production device)
704
+ MPYTOOL_TEST_PORT=/dev/ttyACM0 python -m unittest tests.test_integration -v
705
+
706
+ # All integration tests (dedicated test device)
707
+ MPYTOOL_TEST_PORT_RW=/dev/ttyACM0 python -m unittest tests.test_integration -v
708
+
709
+ # CLI tests (no device needed for basic tests)
710
+ python -m unittest tests.test_cli -v
711
+
712
+ # All CLI tests with device
713
+ MPYTOOL_TEST_PORT_RW=/dev/ttyACM0 python -m unittest tests.test_cli -v
714
+ ```
715
+
716
+ Test categories:
717
+ - **Unit tests** (`test_*.py` except `test_integration.py`, `test_cli.py`) - no device needed
718
+ - **Read-only tests** (`MPYTOOL_TEST_PORT`) - safe on any device, no filesystem changes
719
+ - **All tests** (`MPYTOOL_TEST_PORT_RW`) - includes write and flash operations, use dedicated test device
720
+
721
+ ### Install from source
722
+
723
+ ```bash
724
+ pip install -e .
725
+ ```
726
+
695
727
  ## Requirements
696
728
 
697
729
  Working only with MicroPython boards, not with CircuitPython
@@ -23,6 +23,7 @@ mpytool.egg-info/dependency_links.txt
23
23
  mpytool.egg-info/entry_points.txt
24
24
  mpytool.egg-info/requires.txt
25
25
  mpytool.egg-info/top_level.txt
26
+ tests/test_cli.py
26
27
  tests/test_cmd_cp.py
27
28
  tests/test_errors.py
28
29
  tests/test_integration.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mpytool"
7
- version = "2.3.1"
7
+ version = "2.3.2"
8
8
  description = "MPY tool - manage files on devices running MicroPython"
9
9
  readme = "README.md"
10
10
  license = "MIT"