Skip to content

Commit fc4cd1d

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

File tree

3 files changed

+120
-13
lines changed

3 files changed

+120
-13
lines changed

mqterm/jobs.py

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44
from hashlib import sha256
55
from io import BytesIO
66

7+
try:
8+
from os import dupterm
9+
except ImportError:
10+
# unix mpy doesn't have dupterm; define a no-op version
11+
def dupterm(stream_object, index=0):
12+
return stream_object
13+
14+
715
from micropython import const
816

917
from mqterm import VERSION
@@ -22,6 +30,7 @@ def __init__(self, cmd, args=[], client_id="localhost", **kwargs):
2230
f"Wrong number of arguments for {cmd}; expected {self.argc}"
2331
)
2432

33+
self.globals = kwargs.get("globals", {})
2534
self.cmd = cmd
2635
self.args = args
2736
self.client_id = client_id
@@ -45,13 +54,31 @@ def ready(self):
4554
return True
4655

4756
@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()
57+
def from_cmd(cls, cmd_str, client_id=None, globals={}):
58+
"""Create a job from a command string, e.g. 'cat file1.txt'."""
59+
# Split command string into command and following arguments
60+
try:
61+
cmd, remainder = cmd_str.split(" ", 1)
62+
except ValueError:
63+
cmd, remainder = cmd_str, None
64+
65+
# Lookup command in command table
5166
if cmd not in COMMANDS:
5267
raise ValueError(f"Unknown command: '{cmd}'")
5368
job_cls = COMMANDS[cmd]
54-
return job_cls(cmd, args, client_id)
69+
70+
# For eval, preserve the remainder as a single string argument and de-quote it
71+
# Otherwise, split remainder into separate arguments
72+
if remainder:
73+
if cmd == "eval":
74+
args = [remainder.strip("\"'")]
75+
else:
76+
args = remainder.split(" ")
77+
else:
78+
args = []
79+
80+
# Create and return the job instance
81+
return job_cls(cmd, args, client_id, globals=globals)
5582

5683

5784
class SequentialJob(Job):
@@ -266,10 +293,7 @@ def output(self):
266293
op = machine.reset if self.mode == "hard" else machine.soft_reset
267294
except AttributeError:
268295
raise OSError("Operation not supported on this platform")
269-
if self.mode == "hard":
270-
logging.critical(msg)
271-
else:
272-
logging.warning(msg)
296+
logging.critical(msg)
273297

274298
# Schedule reboot in three seconds
275299
async def reboot_callback(op):
@@ -282,6 +306,41 @@ async def reboot_callback(op):
282306
return BytesIO(msg.encode("utf-8"))
283307

284308

309+
class RunPyJob(Job):
310+
"""A job to evaluate Python script on the device."""
311+
312+
argc = 1
313+
314+
def output(self):
315+
"""Eval or exec given Python and return the result."""
316+
expr = self.args[0]
317+
try:
318+
result = self.do_eval(expr)
319+
except SyntaxError: # Not an expression, try exec
320+
result = self.do_exec(expr)
321+
if isinstance(result, str): # Ensure bytes output
322+
result = result.encode("utf-8")
323+
return BytesIO(result)
324+
325+
def do_eval(self, expr):
326+
"""Evaluate a Python expression and return the result."""
327+
op = compile(expr, "<string>", "eval")
328+
result = eval(op, self.globals, None)
329+
return repr(result)
330+
331+
def do_exec(self, expr):
332+
"""Execute a Python statement and return the output."""
333+
out_buf = BytesIO()
334+
old_term = dupterm(out_buf)
335+
try:
336+
op = compile(expr, "<string>", "exec")
337+
exec(op, self.globals, None)
338+
result = out_buf.getvalue().strip()
339+
finally:
340+
dupterm(old_term)
341+
return result
342+
343+
285344
# Map commands to associated job names
286345
COMMANDS = {
287346
"whoami": WhoAmIJob,
@@ -291,4 +350,5 @@ async def reboot_callback(op):
291350
"cp": PutFileJob,
292351
"ota": FirmwareUpdateJob,
293352
"reboot": RebootJob,
353+
"eval": RunPyJob,
294354
}

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: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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,30 @@ 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+
def test_exec(self):
264+
"""RunPyJob should execute a Python script with side effects"""
265+
cmd = """
266+
with open("output.txt", "w") as f:
267+
f.write("Hello, World!")
268+
""".strip()
269+
job = RunPyJob("exec", [cmd])
270+
job_output = job.output().read().decode("utf-8").strip()
271+
file_output = open("output.txt").read().strip()
272+
self.assertEqual(job_output, "") # No output expected
273+
self.assertEqual(file_output, "Hello, World!")
274+
os.remove("output.txt")

0 commit comments

Comments
 (0)