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.
- {mpytool-2.3.1 → mpytool-2.3.2}/PKG-INFO +33 -1
- {mpytool-2.3.1 → mpytool-2.3.2}/README.md +32 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/conn_serial.py +49 -27
- {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/mpytool.py +38 -33
- mpytool-2.3.2/mpytool/utils.py +136 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/mpytool.egg-info/PKG-INFO +33 -1
- {mpytool-2.3.1 → mpytool-2.3.2}/mpytool.egg-info/SOURCES.txt +1 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/pyproject.toml +1 -1
- mpytool-2.3.2/tests/test_cli.py +634 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/tests/test_integration.py +54 -26
- {mpytool-2.3.1 → mpytool-2.3.2}/tests/test_utils.py +81 -36
- mpytool-2.3.1/mpytool/utils.py +0 -100
- {mpytool-2.3.1 → mpytool-2.3.2}/LICENSE +0 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/__init__.py +0 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/cmd_cp.py +0 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/conn.py +0 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/conn_socket.py +0 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/logger.py +0 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/mount.py +0 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/mpy.py +0 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/mpy_comm.py +0 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/mpy_cross.py +0 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/speedtest.py +0 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/terminal.py +0 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/terminal_unix.py +0 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/mpytool/terminal_win.py +0 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/mpytool.egg-info/dependency_links.txt +0 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/mpytool.egg-info/entry_points.txt +0 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/mpytool.egg-info/requires.txt +0 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/mpytool.egg-info/top_level.txt +0 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/setup.cfg +0 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/tests/test_cmd_cp.py +0 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/tests/test_errors.py +0 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/tests/test_mount.py +0 -0
- {mpytool-2.3.1 → mpytool-2.3.2}/tests/test_mpy.py +0 -0
- {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.
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return
|
|
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 (
|
|
121
|
-
if self.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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',
|
|
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',
|
|
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
|
-
|
|
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.
|
|
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
|