Skip to content

Commit 57721e0

Browse files
committed
improved exec and run monitoring
1 parent 845fffe commit 57721e0

4 files changed

Lines changed: 261 additions & 21 deletions

File tree

mpytool/mpy_comm.py

Lines changed: 83 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -176,16 +176,18 @@ def reset_state(self):
176176
self._repl_mode = None
177177
self._raw_paste_supported = None
178178

179-
def exec(self, command, timeout=None):
179+
def exec(self, command, timeout=None, stream=False):
180180
"""Execute command
181181
182182
Arguments:
183183
command: command to execute
184184
timeout: maximum waiting time for result (None = wait forever),
185185
0 = submit only (send code, don't wait for output)
186+
stream: False = return bytes, True = yield chunks,
187+
file-like object = write chunks to it and return b''
186188
187189
Returns:
188-
command STDOUT result
190+
bytes result, generator, or b'' (when stream is file-like)
189191
190192
Raises:
191193
CmdError when command return error
@@ -199,6 +201,10 @@ def exec(self, command, timeout=None):
199201
if timeout == 0:
200202
self._repl_mode = False
201203
return b''
204+
if stream is True:
205+
return self._exec_stream_result(command, timeout)
206+
if stream:
207+
return self._exec_stream_to(command, timeout, stream)
202208
result = self._conn.read_until(CTRL_D, timeout)
203209
if result:
204210
self._log.info('RES: %s', bytes(result))
@@ -280,6 +286,66 @@ def _wait_for_paste_complete(self, timeout):
280286
if byte == CTRL_D:
281287
break
282288

289+
def _read_stdout_chunks(self, timeout):
290+
"""Read stdout chunks until CTRL_D marker.
291+
292+
Yields (chunk, is_last) tuples. Timeout is total wall time.
293+
"""
294+
start_time = _time.time()
295+
while True:
296+
if timeout is not None:
297+
remaining = timeout - (_time.time() - start_time)
298+
if remaining <= 0:
299+
raise _conn.Timeout("Execution timeout")
300+
self._conn._read_to_buffer(wait_timeout=0.001)
301+
idx = self._conn._buffer.find(CTRL_D)
302+
if idx != -1:
303+
chunk = bytes(self._conn._buffer[:idx])
304+
del self._conn._buffer[:idx + len(CTRL_D)]
305+
if chunk:
306+
yield chunk
307+
return
308+
if self._conn._buffer:
309+
yield bytes(self._conn._buffer)
310+
self._conn._buffer.clear()
311+
312+
def _check_stderr(self, command, result, timeout, start_time):
313+
"""Read stderr and raise CmdError if non-empty."""
314+
if result:
315+
self._log.info('RES: %s', bytes(result))
316+
err_timeout = 5 if timeout is None else max(
317+
1, timeout - (_time.time() - start_time))
318+
err = self._conn.read_until(CTRL_D + b'>', err_timeout)
319+
if err:
320+
raise CmdError(command, bytes(result), err)
321+
322+
def _exec_stream_result(self, command, timeout):
323+
"""Read execution result as streaming generator."""
324+
start_time = _time.time()
325+
result = bytearray()
326+
for chunk in self._read_stdout_chunks(timeout):
327+
result.extend(chunk)
328+
yield chunk
329+
self._check_stderr(command, result, timeout, start_time)
330+
331+
def _exec_stream_to(self, command, timeout, out):
332+
"""Read execution result, writing chunks to file-like object.
333+
334+
Supports both binary and text streams (io.TextIOBase).
335+
"""
336+
import io as _io
337+
start_time = _time.time()
338+
result = bytearray()
339+
text_mode = isinstance(out, _io.TextIOBase)
340+
for chunk in self._read_stdout_chunks(timeout):
341+
result.extend(chunk)
342+
out.write(
343+
chunk.decode('utf-8', 'backslashreplace')
344+
if text_mode else chunk)
345+
out.flush()
346+
self._check_stderr(command, result, timeout, start_time)
347+
return b''
348+
283349
def _read_execution_result(self, command, timeout):
284350
"""Read and parse execution result."""
285351
result = self._conn.read_until(CTRL_D, timeout)
@@ -290,15 +356,17 @@ def _read_execution_result(self, command, timeout):
290356
raise CmdError(command.decode('utf-8', errors='replace'), result, err)
291357
return result
292358

293-
def exec_raw_paste(self, command, timeout=5):
359+
def exec_raw_paste(self, command, timeout=5, stream=False):
294360
"""Execute code via raw-paste mode (flow-controlled, less RAM).
295361
296362
Arguments:
297363
command: code to execute (str or bytes)
298364
timeout: max wait time (0 = submit only, don't wait for output)
365+
stream: False = return bytes, True = yield chunks,
366+
file-like object = write chunks to it and return b''
299367
300368
Returns:
301-
command STDOUT result
369+
bytes result, generator, or b'' (when stream is file-like)
302370
303371
Raises:
304372
CmdError: command execution error
@@ -338,26 +406,32 @@ def exec_raw_paste(self, command, timeout=5):
338406
self._repl_mode = False
339407
return b''
340408

409+
if stream is True:
410+
return self._exec_stream_result(command, timeout)
411+
if stream:
412+
return self._exec_stream_to(command, timeout, stream)
341413
return self._read_execution_result(command, timeout)
342414

343-
def try_raw_paste(self, command, timeout=5):
415+
def try_raw_paste(self, command, timeout=5, stream=False):
344416
"""Try raw-paste mode, fall back to regular exec if not supported.
345417
346418
Arguments:
347419
command: command to execute
348420
timeout: maximum waiting time for result
421+
stream: False = return bytes, True = yield chunks,
422+
file-like object = write chunks to it and return b''
349423
350424
Returns:
351-
command STDOUT result
425+
bytes result, generator, or b'' (when stream is file-like)
352426
"""
353427
# If we know raw-paste is not supported, skip it
354428
if self._raw_paste_supported is False:
355-
return self.exec(command, timeout)
429+
return self.exec(command, timeout, stream=stream)
356430

357431
try:
358-
return self.exec_raw_paste(command, timeout)
432+
return self.exec_raw_paste(command, timeout, stream=stream)
359433
except MpyError as e:
360434
if "not supported" in str(e):
361435
self._log.info("Raw-paste not supported, using regular exec")
362-
return self.exec(command, timeout)
436+
return self.exec(command, timeout, stream=stream)
363437
raise

mpytool/mpytool.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -921,24 +921,39 @@ def _dispatch_repl(self, commands, is_last_group):
921921

922922
@command('exec', 'Execute Python code on device.')
923923
@argument('code', help='Python code to execute')
924+
@option('-t', '--timeout', type=float, default=None,
925+
help='timeout in seconds (0 = fire-and-forget)')
924926
def _dispatch_exec(self, commands, is_last_group):
925-
args = _make_parser(self._dispatch_exec).parse_args([commands.pop(0)])
927+
args = _make_parser(self._dispatch_exec).parse_args(commands)
928+
commands.clear()
926929
self.verbose(f"EXEC: {args.code}", 1)
927-
result = self.mpy.comm.exec(args.code)
928-
if result:
929-
print(result.decode('utf-8', 'backslashreplace'), end='')
930+
timeout = args.timeout
931+
if timeout == 0:
932+
self.mpy.comm.try_raw_paste(args.code, timeout=0)
933+
return
934+
out = getattr(_sys.stdout, 'buffer', _sys.stdout)
935+
self.mpy.comm.try_raw_paste(
936+
args.code, timeout=timeout, stream=out)
930937

931938
@command('run', 'Run local Python file on device.')
932939
@argument('file', metavar='local_file', help='local .py file')
940+
@option('-t', '--timeout', type=float, default=None,
941+
help='timeout in seconds (0 = fire-and-forget)')
933942
def _dispatch_run(self, commands, is_last_group):
934-
arg_list = [commands.pop(0)] if commands else []
935-
args = _make_parser(self._dispatch_run).parse_args(arg_list)
943+
args = _make_parser(self._dispatch_run).parse_args(commands)
944+
commands.clear()
936945
if not _os.path.isfile(args.file):
937946
raise ParamsError(f"file not found: {args.file}")
938947
with open(args.file, 'rb') as f:
939948
code = f.read()
940949
self.verbose(f"RUN: {args.file} ({len(code)} bytes)", 1)
941-
self.mpy.comm.try_raw_paste(code, timeout=0)
950+
timeout = args.timeout
951+
if timeout == 0:
952+
self.mpy.comm.try_raw_paste(code, timeout=0)
953+
return
954+
out = getattr(_sys.stdout, 'buffer', _sys.stdout)
955+
self.mpy.comm.try_raw_paste(
956+
code, timeout=timeout, stream=out)
942957

943958
def _get_editor(self, editor_arg=None):
944959
"""Get editor from --editor, $VISUAL, or $EDITOR"""

tests/test_mpy.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,149 @@ def test_try_raw_paste_timeout_zero_fallback_exec(self):
562562
self.assertFalse(self.comm._repl_mode)
563563

564564

565+
class TestExecStream(unittest.TestCase):
566+
"""Tests for exec() stream parameter (False, True, file-like object)"""
567+
568+
def setUp(self):
569+
self.mock_conn = Mock()
570+
self.mock_conn.read_until.return_value = b''
571+
self.mock_conn.flush.return_value = None
572+
self.comm = mpy_comm.MpyComm(self.mock_conn)
573+
self.comm._repl_mode = True
574+
575+
def _setup_exec_response(self, stdout_data, stderr_data=b''):
576+
"""Configure mock_conn.read_until to return stdout then stderr."""
577+
self.mock_conn.read_until.side_effect = [
578+
b'', # read_until(b'OK')
579+
stdout_data, # read_until(CTRL_D) - stdout
580+
stderr_data, # read_until(CTRL_D + b'>') - stderr
581+
]
582+
583+
def _setup_stream_buffer(self, data):
584+
"""Configure mock_conn._buffer for streaming reads.
585+
586+
Simulates data arriving in _buffer, terminated by CTRL_D + CTRL_D>.
587+
"""
588+
buf = bytearray(data + mpy_comm.CTRL_D)
589+
self.mock_conn._buffer = buf
590+
self.mock_conn._read_to_buffer.return_value = False
591+
# After stdout CTRL_D is consumed, read_until for stderr
592+
self.mock_conn.read_until.side_effect = [
593+
b'', # read_until(b'OK')
594+
b'', # read_until(CTRL_D + b'>') - no error
595+
]
596+
597+
# --- stream=False (default) ---
598+
599+
def test_default_returns_bytes(self):
600+
"""exec(cmd) returns bytes result"""
601+
self._setup_exec_response(b'hello\r\n')
602+
result = self.comm.exec("print('hello')")
603+
self.assertEqual(result, b'hello\r\n')
604+
605+
def test_default_returns_empty_bytes(self):
606+
"""exec(cmd) with no output returns empty bytes"""
607+
self._setup_exec_response(b'')
608+
result = self.comm.exec("x=1")
609+
self.assertEqual(result, b'')
610+
611+
# --- stream=True (generator) ---
612+
613+
def test_yield_returns_generator(self):
614+
"""exec(cmd, stream=True) returns a generator"""
615+
import types
616+
self._setup_stream_buffer(b'data')
617+
gen = self.comm.exec("print('data')", stream=True)
618+
self.assertIsInstance(gen, types.GeneratorType)
619+
# Consume to avoid ResourceWarning
620+
list(gen)
621+
622+
def test_yield_produces_data(self):
623+
"""exec(cmd, stream=True) yields all data"""
624+
self._setup_stream_buffer(b'hello world')
625+
chunks = list(self.comm.exec("print('hello world')", stream=True))
626+
self.assertEqual(b''.join(chunks), b'hello world')
627+
628+
def test_yield_empty_output(self):
629+
"""exec(cmd, stream=True) with no output yields nothing"""
630+
self._setup_stream_buffer(b'')
631+
chunks = list(self.comm.exec("x=1", stream=True))
632+
self.assertEqual(chunks, [])
633+
634+
def test_yield_raises_cmd_error(self):
635+
"""exec(cmd, stream=True) raises CmdError on stderr"""
636+
buf = bytearray(b'partial' + mpy_comm.CTRL_D)
637+
self.mock_conn._buffer = buf
638+
self.mock_conn._read_to_buffer.return_value = False
639+
self.mock_conn.read_until.side_effect = [
640+
b'', # read_until(b'OK')
641+
b'NameError: xxx', # read_until(CTRL_D + b'>') - error
642+
]
643+
with self.assertRaises(mpy_comm.CmdError):
644+
list(self.comm.exec("xxx", stream=True))
645+
646+
# --- stream=binary file object ---
647+
648+
def test_binary_stream_writes_bytes(self):
649+
"""exec(cmd, stream=BytesIO) writes bytes to stream"""
650+
import io
651+
self._setup_stream_buffer(b'binary output')
652+
out = io.BytesIO()
653+
result = self.comm.exec("print('binary output')", stream=out)
654+
self.assertEqual(result, b'')
655+
self.assertEqual(out.getvalue(), b'binary output')
656+
657+
def test_binary_stream_empty_output(self):
658+
"""exec(cmd, stream=BytesIO) with no output writes nothing"""
659+
import io
660+
self._setup_stream_buffer(b'')
661+
out = io.BytesIO()
662+
self.comm.exec("x=1", stream=out)
663+
self.assertEqual(out.getvalue(), b'')
664+
665+
# --- stream=text file object ---
666+
667+
def test_text_stream_writes_str(self):
668+
"""exec(cmd, stream=StringIO) writes decoded str to stream"""
669+
import io
670+
self._setup_stream_buffer(b'text output')
671+
out = io.StringIO()
672+
result = self.comm.exec("print('text output')", stream=out)
673+
self.assertEqual(result, b'')
674+
self.assertEqual(out.getvalue(), 'text output')
675+
676+
def test_text_stream_decodes_utf8(self):
677+
"""exec(cmd, stream=StringIO) decodes UTF-8 correctly"""
678+
import io
679+
self._setup_stream_buffer('príliš žluťoučký'.encode('utf-8'))
680+
out = io.StringIO()
681+
self.comm.exec("print('...')", stream=out)
682+
self.assertEqual(out.getvalue(), 'príliš žluťoučký')
683+
684+
def test_text_stream_handles_invalid_utf8(self):
685+
"""exec(cmd, stream=StringIO) uses backslashreplace for invalid bytes"""
686+
import io
687+
self._setup_stream_buffer(b'ok\xff\xfebad')
688+
out = io.StringIO()
689+
self.comm.exec("cmd", stream=out)
690+
self.assertIn('ok', out.getvalue())
691+
self.assertIn('bad', out.getvalue())
692+
693+
def test_text_stream_raises_cmd_error(self):
694+
"""exec(cmd, stream=StringIO) raises CmdError on stderr"""
695+
import io
696+
buf = bytearray(b'out' + mpy_comm.CTRL_D)
697+
self.mock_conn._buffer = buf
698+
self.mock_conn._read_to_buffer.return_value = False
699+
self.mock_conn.read_until.side_effect = [
700+
b'',
701+
b'SyntaxError',
702+
]
703+
out = io.StringIO()
704+
with self.assertRaises(mpy_comm.CmdError):
705+
self.comm.exec("bad", stream=out)
706+
707+
565708
class TestCwdAndPathTracking(unittest.TestCase):
566709
"""Tests for CWD and sys.path tracking for soft reset restore"""
567710

tests/test_mpytool.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -712,25 +712,33 @@ def test_run_file_not_found(self):
712712
self.assertIn('not found', str(ctx.exception).lower())
713713

714714
def test_run_sends_file_content(self):
715-
"""'run script.py' -> reads file and sends via try_raw_paste"""
715+
"""'run script.py' -> reads file and sends via try_raw_paste (streaming)"""
716716
self.tool.process_commands(['run', self.test_file])
717-
self.tool._mpy.comm.try_raw_paste.assert_called_once_with(
718-
b"print('hello')\n", timeout=0)
717+
args, kwargs = self.tool._mpy.comm.try_raw_paste.call_args
718+
self.assertEqual(args[0], b"print('hello')\n")
719+
self.assertIsNone(kwargs['timeout'])
720+
self.assertNotIn(kwargs['stream'], (False, True)) # file-like object
719721

720722
def test_run_reads_binary_mode(self):
721723
"""run reads file as bytes (rb), preserving encoding"""
722724
utf8_file = os.path.join(self.temp_dir, "utf8.py")
723725
with open(utf8_file, 'wb') as f:
724726
f.write("print('súbor')\n".encode('utf-8'))
725727
self.tool.process_commands(['run', utf8_file])
726-
self.tool._mpy.comm.try_raw_paste.assert_called_once_with(
727-
"print('súbor')\n".encode('utf-8'), timeout=0)
728+
args, kwargs = self.tool._mpy.comm.try_raw_paste.call_args
729+
self.assertEqual(args[0], "print('súbor')\n".encode('utf-8'))
728730

729731
def test_run_with_command_separator(self):
730732
"""'run script.py -- ls' -> run then ls"""
731733
self.tool.process_commands(['run', self.test_file])
732734
self.tool._mpy.comm.try_raw_paste.assert_called_once()
733735

736+
def test_run_fire_and_forget(self):
737+
"""'run script.py -t 0' -> fire-and-forget (timeout=0)"""
738+
self.tool.process_commands(['run', self.test_file, '-t', '0'])
739+
self.tool._mpy.comm.try_raw_paste.assert_called_once_with(
740+
b"print('hello')\n", timeout=0)
741+
734742

735743
class TestPathCommand(unittest.TestCase):
736744
"""Tests for path command dispatch"""

0 commit comments

Comments
 (0)