Skip to content

Commit de6e894

Browse files
committed
Implement the RunPyJob for eval/exec
Closes #23
1 parent 5eae068 commit de6e894

File tree

3 files changed

+108
-14
lines changed

3 files changed

+108
-14
lines changed

mqterm/jobs.py

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
import logging
3+
import os
34
from binascii import hexlify
45
from hashlib import sha256
56
from io import BytesIO
@@ -22,6 +23,7 @@ def __init__(self, cmd, args=[], client_id="localhost", **kwargs):
2223
f"Wrong number of arguments for {cmd}; expected {self.argc}"
2324
)
2425

26+
self.globals = kwargs.get("globals", {})
2527
self.cmd = cmd
2628
self.args = args
2729
self.client_id = client_id
@@ -45,13 +47,31 @@ def ready(self):
4547
return True
4648

4749
@classmethod
48-
def from_cmd(cls, cmd_str, client_id=None):
49-
"""Create a job from a command string, e.g. 'get_file file1.txt'."""
50-
cmd, *args = cmd_str.split()
50+
def from_cmd(cls, cmd_str, client_id=None, globals={}):
51+
"""Create a job from a command string, e.g. 'cat file1.txt'."""
52+
# Split command string into command and following arguments
53+
try:
54+
cmd, remainder = cmd_str.split(" ", 1)
55+
except ValueError:
56+
cmd, remainder = cmd_str, None
57+
58+
# Lookup command in command table
5159
if cmd not in COMMANDS:
5260
raise ValueError(f"Unknown command: '{cmd}'")
5361
job_cls = COMMANDS[cmd]
54-
return job_cls(cmd, args, client_id)
62+
63+
# For eval, preserve the remainder as a single string argument and de-quote it
64+
# Otherwise, split remainder into separate arguments
65+
if remainder:
66+
if cmd == "eval":
67+
args = [remainder.strip("\"'")]
68+
else:
69+
args = remainder.split(" ")
70+
else:
71+
args = []
72+
73+
# Create and return the job instance
74+
return job_cls(cmd, args, client_id, globals=globals)
5575

5676

5777
class SequentialJob(Job):
@@ -266,10 +286,7 @@ def output(self):
266286
op = machine.reset if self.mode == "hard" else machine.soft_reset
267287
except AttributeError:
268288
raise OSError("Operation not supported on this platform")
269-
if self.mode == "hard":
270-
logging.critical(msg)
271-
else:
272-
logging.warning(msg)
289+
logging.critical(msg)
273290

274291
# Schedule reboot in three seconds
275292
async def reboot_callback(op):
@@ -282,6 +299,41 @@ async def reboot_callback(op):
282299
return BytesIO(msg.encode("utf-8"))
283300

284301

302+
class RunPyJob(Job):
303+
"""A job to evaluate Python script on the device."""
304+
305+
argc = 1
306+
307+
def output(self):
308+
"""Eval or exec given Python and return the result."""
309+
expr = self.args[0]
310+
try:
311+
result = self.do_eval(expr)
312+
except SyntaxError: # Not an expression, try exec
313+
result = self.do_exec(expr)
314+
if isinstance(result, str): # Ensure bytes output
315+
result = result.encode("utf-8")
316+
return BytesIO(result)
317+
318+
def do_eval(self, expr):
319+
"""Evaluate a Python expression and return the result."""
320+
op = compile(expr, "<string>", "eval")
321+
result = eval(op, self.globals, None)
322+
return repr(result)
323+
324+
def do_exec(self, expr):
325+
"""Execute a Python statement and return the output."""
326+
out_buf = BytesIO()
327+
old_term = os.dupterm(out_buf)
328+
try:
329+
op = compile(expr, "<string>", "exec")
330+
exec(op, self.globals, None)
331+
result = out_buf.getvalue()
332+
finally:
333+
os.dupterm(old_term)
334+
return result
335+
336+
285337
# Map commands to associated job names
286338
COMMANDS = {
287339
"whoami": WhoAmIJob,
@@ -291,4 +343,5 @@ async def reboot_callback(op):
291343
"cp": PutFileJob,
292344
"ota": FirmwareUpdateJob,
293345
"reboot": RebootJob,
346+
"eval": RunPyJob,
294347
}

mqterm/terminal.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@ class MqttTerminal:
4444
BUFLEN = PKTLEN * 2 # payload size for MQTT messages
4545

4646
def __init__(
47-
self, mqtt_client, topic_prefix=None, logger=logging.getLogger("mqterm")
47+
self,
48+
mqtt_client,
49+
topic_prefix=None,
50+
logger=logging.getLogger("mqterm"),
51+
globals={},
4852
):
4953
self.mqtt_client = mqtt_client
5054
self.topic_prefix = topic_prefix
@@ -55,6 +59,7 @@ def __init__(
5559
self.out_view = memoryview(self.out_buffer)
5660
self.logger = logger
5761
self.jobs = {}
62+
self.globals = globals
5863

5964
async def connect(self):
6065
"""Start processing messages in the input stream."""
@@ -102,7 +107,7 @@ async def update_job(self, client_id, seq, payload):
102107
self.logger.debug(f"Updated {job}, seq: {seq}")
103108
else:
104109
cmd = payload.decode("utf-8")
105-
job = Job.from_cmd(cmd, client_id=client_id)
110+
job = Job.from_cmd(cmd, client_id=client_id, globals=self.globals)
106111
self.jobs[client_id] = job
107112
self.logger.info(f"Created {job}")
108113

tests/test_jobs.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import os
66
from binascii import hexlify
77
from hashlib import sha256
8-
from unittest import TestCase
8+
from unittest import TestCase, skip
99

1010
from mqterm.jobs import (
1111
FirmwareUpdateJob,
@@ -14,6 +14,7 @@
1414
PlatformInfoJob,
1515
PutFileJob,
1616
RebootJob,
17+
RunPyJob,
1718
WhoAmIJob,
1819
)
1920

@@ -28,6 +29,20 @@ def test_from_cmd(self):
2829
with self.assertRaises(ValueError):
2930
Job.from_cmd("unknown")
3031

32+
def test_from_cmd_eval(self):
33+
"""Job should handle eval command"""
34+
job = Job.from_cmd("eval '1 + 2'")
35+
self.assertEqual(job.cmd, "eval")
36+
self.assertEqual(job.args, ["1 + 2"])
37+
self.assertIsInstance(job, RunPyJob)
38+
39+
def test_from_cmd_no_args(self):
40+
"""Job should handle commands with no arguments"""
41+
job = Job.from_cmd("uname")
42+
self.assertEqual(job.cmd, "uname")
43+
self.assertEqual(job.args, [])
44+
self.assertIsInstance(job, PlatformInfoJob)
45+
3146
def test_str(self):
3247
"""Job should have a string representation"""
3348
job = Job("cat", args=["file.txt"])
@@ -212,11 +227,11 @@ class TestRebootJob(TestCase):
212227
def setUp(self):
213228
# Turn off logging during tests
214229
self.logger = logging.getLogger()
215-
self.old_level = self.logger.level
216-
self.logger.setLevel(logging.CRITICAL)
230+
self.handlers = self.logger.handlers[:]
231+
self.logger.handlers = []
217232

218233
def tearDown(self):
219-
self.logger.setLevel(self.old_level)
234+
self.logger.handlers = self.handlers
220235

221236
def test_run_hard(self):
222237
"""Reboot job should signal and perform a hard reboot"""
@@ -230,3 +245,24 @@ def test_run_soft(self):
230245
job = RebootJob("reboot", ["soft"])
231246
output = job.output().read().decode("utf-8").strip()
232247
self.assertEqual(output, "Performing soft reboot")
248+
249+
250+
class TestRunPyJob(TestCase):
251+
def test_eval(self):
252+
"""RunPyJob should evaluate a Python expression and return result"""
253+
job = RunPyJob("eval", ["1 + 2"])
254+
output = job.output().read().decode("utf-8").strip()
255+
self.assertEqual(output, "3")
256+
257+
def test_globals(self):
258+
"""RunPyJob should use provided globals for evaluation"""
259+
job = RunPyJob("eval", ["x + y"], globals={"x": 1, "y": 2})
260+
output = job.output().read().decode("utf-8").strip()
261+
self.assertEqual(output, "3")
262+
263+
@skip("No dupterm in unix port")
264+
def test_exec(self):
265+
"""RunPyJob should execute a Python script and return output"""
266+
job = RunPyJob("eval", ['print("Hello, World!")'])
267+
output = job.output().read().decode("utf-8").strip()
268+
self.assertEqual(output, "Hello, World!")

0 commit comments

Comments
 (0)