mpytool 1.2.0__py3-none-any.whl → 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mpytool/__init__.py CHANGED
@@ -2,5 +2,7 @@
2
2
 
3
3
  from mpytool.conn import ConnError, Timeout
4
4
  from mpytool.conn_serial import ConnSerial
5
+ from mpytool.conn_socket import ConnSocket
5
6
  from mpytool.mpy_comm import MpyError, CmdError
6
7
  from mpytool.mpy import Mpy
8
+ from mpytool.logger import SimpleColorLogger
mpytool/conn.py CHANGED
@@ -1,5 +1,8 @@
1
1
  """MicroPython tool: abstract connector"""
2
2
 
3
+ import time as _time
4
+ import select as _select
5
+
3
6
 
4
7
  class ConnError(Exception):
5
8
  """General connection error"""
@@ -12,27 +15,86 @@ class Timeout(ConnError):
12
15
  class Conn():
13
16
  def __init__(self, log=None):
14
17
  self._log = log
18
+ self._buffer = bytearray(b'')
15
19
 
16
20
  @property
17
21
  def fd(self):
18
- """Return file descriptor
19
- """
22
+ """Return file descriptor for select()"""
20
23
  return None
21
24
 
22
- def read(self):
23
- """Read available data from device
24
- """
25
+ def _has_data(self, timeout=0):
26
+ """Check if data is available to read using select()"""
27
+ fd = self.fd
28
+ if fd is None:
29
+ return False
30
+ readable, _, _ = _select.select([fd], [], [], timeout)
31
+ return bool(readable)
32
+
33
+ def _read_available(self):
34
+ """Read available data from device (must be implemented by subclass)"""
35
+ raise NotImplementedError
36
+
37
+ def _write_raw(self, data):
38
+ """Write data to device, return bytes written (must be implemented by subclass)"""
39
+ raise NotImplementedError
40
+
41
+ def _read_to_buffer(self, wait_timeout=0):
42
+ """Read available data into buffer
25
43
 
26
- def write(self, data, chunk_size=128, delay=0.01):
27
- """Write to device
44
+ Arguments:
45
+ wait_timeout: how long to wait for data (0 = non-blocking)
28
46
  """
47
+ if self._has_data(wait_timeout):
48
+ data = self._read_available()
49
+ if data:
50
+ self._buffer += data
51
+ return True
52
+ return False
53
+
54
+ def flush(self):
55
+ """Flush and return buffer contents"""
56
+ buffer = bytes(self._buffer)
57
+ del self._buffer[:]
58
+ return buffer
59
+
60
+ def read(self):
61
+ """Read available data from device"""
62
+ if self._has_data():
63
+ return self._read_available()
64
+ return None
65
+
66
+ def write(self, data):
67
+ """Write data to device"""
68
+ if self._log:
69
+ self._log.debug("wr: %s", bytes(data))
70
+ while data:
71
+ count = self._write_raw(data)
72
+ data = data[count:]
29
73
 
30
74
  def read_until(self, end, timeout=1):
31
- """Read until
32
- """
33
- return ''
75
+ """Read until end marker is found"""
76
+ if self._log:
77
+ self._log.debug("wait for %s", end)
78
+ start_time = _time.time()
79
+ while True:
80
+ # Use select() with 1ms timeout instead of sleep - wakes immediately on data
81
+ if self._read_to_buffer(wait_timeout=0.001):
82
+ start_time = _time.time() # reset timeout on data received
83
+ if end in self._buffer:
84
+ break
85
+ if timeout is not None and start_time + timeout < _time.time():
86
+ if self._buffer:
87
+ raise Timeout(
88
+ f"During timeout received: {bytes(self._buffer)}")
89
+ raise Timeout("No data received")
90
+ index = self._buffer.index(end)
91
+ data = self._buffer[:index]
92
+ del self._buffer[:index + len(end)]
93
+ if self._log:
94
+ self._log.debug("rd: %s", bytes(data + end))
95
+ return data
34
96
 
35
97
  def read_line(self, timeout=None):
36
- """Read signle line"""
98
+ """Read single line"""
37
99
  line = self.read_until(b'\n', timeout)
38
100
  return line.strip(b'\r')
mpytool/conn_serial.py CHANGED
@@ -1,6 +1,5 @@
1
1
  """MicroPython tool: serial connector"""
2
2
 
3
- import time as _time
4
3
  import serial as _serial
5
4
  import mpytool.conn as _conn
6
5
 
@@ -8,60 +7,28 @@ import mpytool.conn as _conn
8
7
  class ConnSerial(_conn.Conn):
9
8
  def __init__(self, log=None, **serial_config):
10
9
  super().__init__(log)
11
- self._serial = _serial.Serial(**serial_config)
12
- self._buffer = bytearray(b'')
10
+ try:
11
+ self._serial = _serial.Serial(**serial_config)
12
+ except _serial.serialutil.SerialException as err:
13
+ self._serial = None
14
+ raise _conn.ConnError(
15
+ f"Error opening serial port {serial_config['port']}") from err
13
16
 
14
17
  def __del__(self):
15
18
  if self._serial:
16
19
  self._serial.close()
17
20
 
18
- def _read_to_buffer(self):
19
- in_waiting = self._serial.in_waiting
20
- if in_waiting > 0:
21
- self._buffer += self._serial.read(in_waiting)
22
- return True
23
- return False
24
-
25
21
  @property
26
22
  def fd(self):
27
- return self._serial.fd
23
+ return self._serial.fd if self._serial else None
28
24
 
29
- def read(self):
30
- self._read_to_buffer()
31
- if self._buffer:
32
- data = self._buffer[:]
33
- del self._buffer[:]
34
- return data
25
+ def _read_available(self):
26
+ """Read available data from serial port"""
27
+ in_waiting = self._serial.in_waiting
28
+ if in_waiting > 0:
29
+ return self._serial.read(in_waiting)
35
30
  return None
36
31
 
37
- def write(self, data, chunk_size=128, delay=0.01):
38
- if self._log:
39
- self._log.debug(f"wr: {data}")
40
- while data:
41
- chunk = data[:chunk_size]
42
- count = self._serial.write(chunk)
43
- data = data[count:]
44
- _time.sleep(delay)
45
-
46
- def read_until(self, end, timeout=1):
47
- if self._log:
48
- self._log.debug(f'wait for {end}')
49
- start_time = _time.time()
50
- while True:
51
- if self._read_to_buffer():
52
- start_time = _time.time()
53
- if end in self._buffer:
54
- break
55
- if timeout is not None and start_time + timeout < _time.time():
56
- if self._buffer:
57
- raise _conn.Timeout(
58
- f"During timeout received: {bytes(self._buffer)}")
59
- raise _conn.Timeout("No data received")
60
- _time.sleep(.01)
61
- index = self._buffer.index(end)
62
- data = self._buffer[:index]
63
- del self._buffer[:index + len(end)]
64
- # data, self._buffer = self._buffer.split(end, 1)
65
- if self._log:
66
- self._log.debug(f"rd: {data + end}")
67
- return data
32
+ def _write_raw(self, data):
33
+ """Write data to serial port"""
34
+ return self._serial.write(data)
mpytool/conn_socket.py ADDED
@@ -0,0 +1,53 @@
1
+ """MicroPython tool: socket connector"""
2
+
3
+ import socket as _socket
4
+ import mpytool.conn as _conn
5
+
6
+
7
+ class ConnSocket(_conn.Conn):
8
+ def __init__(self, address, log=None):
9
+ super().__init__(log)
10
+ self._socket = None
11
+ sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
12
+ sock.settimeout(5)
13
+ if ':' in address:
14
+ host, port = address.split(':')
15
+ port = int(port)
16
+ else:
17
+ host = address
18
+ port = 23
19
+ if log:
20
+ log.info(f"Connecting to: {host}:{port}")
21
+ try:
22
+ sock.connect((host, port))
23
+ except _socket.timeout as err:
24
+ raise _conn.ConnError(f"Timeout connecting to {host}:{port}") from err
25
+ except _socket.error as err:
26
+ raise _conn.ConnError(f"Cannot connect to {host}:{port}: {err}") from err
27
+ sock.settimeout(None)
28
+ sock.setblocking(False)
29
+ self._socket = sock
30
+ if log:
31
+ log.info("connected")
32
+
33
+ def __del__(self):
34
+ if self._socket:
35
+ self._socket.close()
36
+
37
+ @property
38
+ def fd(self):
39
+ return self._socket.fileno() if self._socket else None
40
+
41
+ def _read_available(self):
42
+ """Read available data from socket"""
43
+ try:
44
+ data = self._socket.recv(4096)
45
+ if data:
46
+ return data
47
+ except BlockingIOError:
48
+ pass
49
+ return None
50
+
51
+ def _write_raw(self, data):
52
+ """Write data to socket"""
53
+ return self._socket.send(data)
mpytool/logger.py ADDED
@@ -0,0 +1,87 @@
1
+ """Simple color logger for mpytool"""
2
+
3
+ import os as _os
4
+ import sys as _sys
5
+
6
+
7
+ class SimpleColorLogger():
8
+ # ANSI color codes
9
+ _RESET = '\033[0m'
10
+ _BOLD_RED = '\033[1;31m'
11
+ _BOLD_YELLOW = '\033[1;33m'
12
+ _BOLD_MAGENTA = '\033[1;35m'
13
+ _BOLD_BLUE = '\033[1;34m'
14
+ _BOLD_GREEN = '\033[1;32m'
15
+ _BOLD_CYAN = '\033[1;36m'
16
+ _CLEAR_LINE = '\033[K'
17
+
18
+ # Color names for verbose()
19
+ COLORS = {
20
+ 'red': _BOLD_RED,
21
+ 'yellow': _BOLD_YELLOW,
22
+ 'magenta': _BOLD_MAGENTA,
23
+ 'blue': _BOLD_BLUE,
24
+ 'green': _BOLD_GREEN,
25
+ 'cyan': _BOLD_CYAN,
26
+ }
27
+
28
+ def __init__(self, loglevel=1, verbose_level=0):
29
+ self._loglevel = loglevel
30
+ self._verbose_level = verbose_level
31
+ self._is_tty = (
32
+ _sys.stderr.isatty()
33
+ and _os.environ.get('NO_COLOR') is None
34
+ and _os.environ.get('TERM') != 'dumb'
35
+ and _os.environ.get('CI') is None
36
+ )
37
+
38
+ def log(self, msg):
39
+ print(msg, file=_sys.stderr)
40
+
41
+ def error(self, msg, *args):
42
+ if args:
43
+ msg = msg % args
44
+ if self._loglevel >= 1:
45
+ if self._is_tty:
46
+ self.log(f"{self._BOLD_RED}{msg}{self._RESET}")
47
+ else:
48
+ self.log(f"E: {msg}")
49
+
50
+ def warning(self, msg, *args):
51
+ if args:
52
+ msg = msg % args
53
+ if self._loglevel >= 2:
54
+ if self._is_tty:
55
+ self.log(f"{self._BOLD_YELLOW}{msg}{self._RESET}")
56
+ else:
57
+ self.log(f"W: {msg}")
58
+
59
+ def info(self, msg, *args):
60
+ if args:
61
+ msg = msg % args
62
+ if self._loglevel >= 3:
63
+ if self._is_tty:
64
+ self.log(f"{self._BOLD_MAGENTA}{msg}{self._RESET}")
65
+ else:
66
+ self.log(f"I: {msg}")
67
+
68
+ def debug(self, msg, *args):
69
+ if args:
70
+ msg = msg % args
71
+ if self._loglevel >= 4:
72
+ if self._is_tty:
73
+ self.log(f"{self._BOLD_BLUE}{msg}{self._RESET}")
74
+ else:
75
+ self.log(f"D: {msg}")
76
+
77
+ def verbose(self, msg, level=1, color='green', end='\n', overwrite=False):
78
+ """Print verbose message if verbose_level >= level"""
79
+ if self._verbose_level < level:
80
+ return
81
+ # Skip progress updates (overwrite without newline) in non-TTY mode
82
+ if overwrite and not self._is_tty and end != '\n':
83
+ 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 ''
87
+ print(f"{clear}{color_code}{msg}{reset_code}", end=end, file=_sys.stderr, flush=True)
mpytool/mpy.py CHANGED
@@ -3,6 +3,11 @@
3
3
  import mpytool.mpy_comm as _mpy_comm
4
4
 
5
5
 
6
+ def _escape_path(path: str) -> str:
7
+ """Escape path for use in Python string literal"""
8
+ return path.replace("\\", "\\\\").replace("'", "\\'")
9
+
10
+
6
11
  class PathNotFound(_mpy_comm.MpyError):
7
12
  """File not found"""
8
13
  def __init__(self, file_name):
@@ -26,7 +31,7 @@ class DirNotFound(PathNotFound):
26
31
 
27
32
 
28
33
  class Mpy():
29
- _CHUNK = 4096
34
+ _CHUNK = 512
30
35
  _ATTR_DIR = 0x4000
31
36
  _ATTR_FILE = 0x8000
32
37
  _HELPERS = {
@@ -91,6 +96,18 @@ def _mpytool_rmdir(path):
91
96
  elif attr == {_ATTR_DIR}:
92
97
  _mpytool_rmdir(path + '/' + name)
93
98
  os.rmdir(path)
99
+ """,
100
+ 'hashfile': """
101
+ def _mpytool_hashfile(path):
102
+ import hashlib
103
+ h = hashlib.sha256()
104
+ with open(path, 'rb') as f:
105
+ while True:
106
+ chunk = f.read(512)
107
+ if not chunk:
108
+ break
109
+ h.update(chunk)
110
+ return h.digest()
94
111
  """}
95
112
 
96
113
  def __init__(self, conn, log=None):
@@ -112,6 +129,14 @@ def _mpytool_rmdir(path):
112
129
  """
113
130
  return self._mpy_comm
114
131
 
132
+ def reset_state(self):
133
+ """Reset internal state after device reset
134
+
135
+ Call this after soft_reset() to clear cached helper/import state.
136
+ """
137
+ self._imported = []
138
+ self._load_helpers = []
139
+
115
140
  def load_helper(self, helper):
116
141
  """Load helper function to MicroPython
117
142
 
@@ -147,7 +172,7 @@ def _mpytool_rmdir(path):
147
172
  """
148
173
  self.import_module('os')
149
174
  self.load_helper('stat')
150
- return self._mpy_comm.exec_eval(f"_mpytool_stat('{path}')")
175
+ return self._mpy_comm.exec_eval(f"_mpytool_stat('{_escape_path(path)}')")
151
176
 
152
177
  def ls(self, path=None):
153
178
  """List files on path
@@ -166,7 +191,7 @@ def _mpytool_rmdir(path):
166
191
  path = ''
167
192
  try:
168
193
  result = self._mpy_comm.exec_eval(
169
- f"tuple(os.ilistdir('{path}'))")
194
+ f"tuple(os.ilistdir('{_escape_path(path)}'))")
170
195
  res_dir = []
171
196
  res_file = []
172
197
  for entry in result:
@@ -199,14 +224,14 @@ def _mpytool_rmdir(path):
199
224
  if path is None:
200
225
  path = ''
201
226
  if path in ('', '.', '/'):
202
- return self._mpy_comm.exec_eval(f"_mpytool_tree('{path}')")
227
+ return self._mpy_comm.exec_eval(f"_mpytool_tree('{_escape_path(path)}')")
203
228
  # check if path exists
204
229
  result = self.stat(path)
205
230
  if result is None:
206
231
  raise DirNotFound(path)
207
232
  if result == -1:
208
- return self._mpy_comm.exec_eval(f"_mpytool_tree('{path}')")
209
- return((path, result[6], None))
233
+ return self._mpy_comm.exec_eval(f"_mpytool_tree('{_escape_path(path)}')")
234
+ return (path, result, None)
210
235
 
211
236
  def mkdir(self, path):
212
237
  """make directory (also create all parents)
@@ -216,7 +241,7 @@ def _mpytool_rmdir(path):
216
241
  """
217
242
  self.import_module('os')
218
243
  self.load_helper('mkdir')
219
- if self._mpy_comm.exec_eval(f"_mpytool_mkdir('{path}')"):
244
+ if self._mpy_comm.exec_eval(f"_mpytool_mkdir('{_escape_path(path)}')"):
220
245
  raise _mpy_comm.MpyError(f'Error creating directory, this is file: {path}')
221
246
 
222
247
  def delete(self, path):
@@ -231,21 +256,53 @@ def _mpytool_rmdir(path):
231
256
  if result == -1:
232
257
  self.import_module('os')
233
258
  self.load_helper('rmdir')
234
- self._mpy_comm.exec(f"_mpytool_rmdir('{path}')")
259
+ self._mpy_comm.exec(f"_mpytool_rmdir('{_escape_path(path)}')", 20)
235
260
  else:
236
- self._mpy_comm.exec(f"os.remove('{path}')")
261
+ self._mpy_comm.exec(f"os.remove('{_escape_path(path)}')")
262
+
263
+ def rename(self, src, dst):
264
+ """Rename/move file or directory
237
265
 
238
- def get(self, path):
266
+ Arguments:
267
+ src: source path
268
+ dst: destination path
269
+ """
270
+ self.import_module('os')
271
+ self._mpy_comm.exec(f"os.rename('{_escape_path(src)}', '{_escape_path(dst)}')")
272
+
273
+ def hashfile(self, path):
274
+ """Compute SHA256 hash of file
275
+
276
+ Arguments:
277
+ path: file path
278
+
279
+ Returns:
280
+ bytes with SHA256 hash (32 bytes) or None if hashlib not available
281
+ """
282
+ self.load_helper('hashfile')
283
+ try:
284
+ return self._mpy_comm.exec_eval(f"_mpytool_hashfile('{_escape_path(path)}')")
285
+ except _mpy_comm.CmdError:
286
+ return None
287
+
288
+ def get(self, path, progress_callback=None):
239
289
  """Read file
240
290
 
241
291
  Arguments:
242
292
  path: file path to read
293
+ progress_callback: optional callback(transferred, total) for progress
243
294
 
244
295
  Returns:
245
296
  bytes with file content
246
297
  """
298
+ # Get file size first if callback provided
299
+ total_size = 0
300
+ if progress_callback:
301
+ total_size = self.stat(path)
302
+ if total_size is None or total_size < 0:
303
+ total_size = 0
247
304
  try:
248
- self._mpy_comm.exec(f"f = open('{path}', 'rb')")
305
+ self._mpy_comm.exec(f"f = open('{_escape_path(path)}', 'rb')")
249
306
  except _mpy_comm.CmdError as err:
250
307
  raise FileNotFound(path) from err
251
308
  data = b''
@@ -254,19 +311,27 @@ def _mpytool_rmdir(path):
254
311
  if not result:
255
312
  break
256
313
  data += result
314
+ if progress_callback:
315
+ progress_callback(len(data), total_size)
257
316
  self._mpy_comm.exec("f.close()")
258
317
  return data
259
318
 
260
- def put(self, data, path):
261
- """Read file
319
+ def put(self, data, path, progress_callback=None):
320
+ """Write file to device
262
321
 
263
322
  Arguments:
264
323
  data: bytes with file content
265
324
  path: file path to write
325
+ progress_callback: optional callback(transferred, total) for progress
266
326
  """
267
- self._mpy_comm.exec(f"f = open('{path}', 'wb')")
327
+ total_size = len(data)
328
+ transferred = 0
329
+ self._mpy_comm.exec(f"f = open('{_escape_path(path)}', 'wb')")
268
330
  while data:
269
331
  chunk = data[:self._CHUNK]
270
332
  count = self._mpy_comm.exec_eval(f"f.write({chunk})", timeout=10)
271
333
  data = data[count:]
334
+ transferred += count
335
+ if progress_callback:
336
+ progress_callback(transferred, total_size)
272
337
  self._mpy_comm.exec("f.close()")
mpytool/mpy_comm.py CHANGED
@@ -8,7 +8,7 @@ class MpyError(Exception):
8
8
 
9
9
 
10
10
  class CmdError(MpyError):
11
- """Timeout"""
11
+ """Command execution error on device"""
12
12
  def __init__(self, cmd, result, error):
13
13
  self._cmd = cmd
14
14
  self._result = result
@@ -47,24 +47,53 @@ class MpyComm():
47
47
  return self._conn
48
48
 
49
49
  def stop_current_operation(self):
50
+ """Stop any running operation and get to known REPL state.
51
+
52
+ Some USB/serial converters reset the device when port opens.
53
+ We send multiple Ctrl-C/Ctrl-B with short timeouts to catch the device
54
+ after reset completes and program starts running.
55
+
56
+ Returns:
57
+ True if we're in a known REPL state, False if recovery failed
58
+ """
50
59
  if self._repl_mode is not None:
51
- return
60
+ return True
52
61
  if self._log:
53
62
  self._log.info('STOP CURRENT OPERATION')
54
- self._conn.write(b'\x03')
55
- try:
56
- # wait for prompt
57
- self._conn.read_until(b'\r\n>>> ', timeout=1)
58
- except _conn.Timeout:
59
- # probably is in RAW repl
60
- if self._log:
61
- self._log.warning("Timeout while stopping program")
62
- self.exit_raw_repl()
63
63
 
64
- def enter_raw_repl(self):
64
+ # Flush any pending data first
65
+ self._conn.flush()
66
+
67
+ # Try multiple attempts with short timeouts
68
+ # Interleave Ctrl-C (interrupt program) and Ctrl-B (exit raw REPL)
69
+ # This handles USB/serial converters that reset device on port open
70
+ for attempt in range(15):
71
+ # Alternate: Ctrl-C, Ctrl-C, Ctrl-B, repeat
72
+ if attempt % 3 == 2:
73
+ self._conn.write(b'\x02') # Ctrl-B - exit raw REPL
74
+ else:
75
+ self._conn.write(b'\x03') # Ctrl-C - interrupt program
76
+ try:
77
+ self._conn.read_until(b'\r\n>>> ', timeout=0.2)
78
+ self._repl_mode = False
79
+ return True
80
+ except _conn.Timeout:
81
+ pass
82
+
83
+ if self._log:
84
+ self._log.warning("Could not establish REPL state")
85
+ return False
86
+
87
+ def enter_raw_repl(self, max_retries=3):
65
88
  if self._repl_mode is True:
66
89
  return
67
- self.stop_current_operation()
90
+ retries = 0
91
+ while not self.stop_current_operation():
92
+ retries += 1
93
+ if retries >= max_retries:
94
+ raise MpyError("Could not establish REPL connection")
95
+ if self._log:
96
+ self._log.warning('..retry %d/%d', retries, max_retries)
68
97
  if self._log:
69
98
  self._log.info('ENTER RAW REPL')
70
99
  self._conn.write(b'\x01')
@@ -105,14 +134,14 @@ class MpyComm():
105
134
  # wait for prompt
106
135
  self.enter_raw_repl()
107
136
  if self._log:
108
- self._log.info(f"CMD: {command}")
137
+ self._log.info("CMD: %s", command)
109
138
  self._conn.write(bytes(command, 'utf-8'))
110
139
  self._conn.write(b'\x04')
111
140
  self._conn.read_until(b'OK', timeout)
112
141
  result = self._conn.read_until(b'\x04', timeout)
113
142
  if result:
114
143
  if self._log:
115
- self._log.info(f'RES: {result}')
144
+ self._log.info('RES: %s', bytes(result))
116
145
  err = self._conn.read_until(b'\x04>', timeout)
117
146
  if err:
118
147
  raise CmdError(command, result, err)