Skip to content

Commit aa6a18c

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

File tree

3 files changed

+69
-5
lines changed

3 files changed

+69
-5
lines changed

mqterm/jobs.py

Lines changed: 41 additions & 3 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,15 @@ def ready(self):
4547
return True
4648

4749
@classmethod
48-
def from_cmd(cls, cmd_str, client_id=None):
50+
def from_cmd(cls, cmd_str, client_id=None, globals={}):
4951
"""Create a job from a command string, e.g. 'get_file file1.txt'."""
50-
cmd, *args = cmd_str.split()
52+
cmd, remainder = cmd_str.split(" ", 1)
5153
if cmd not in COMMANDS:
5254
raise ValueError(f"Unknown command: '{cmd}'")
5355
job_cls = COMMANDS[cmd]
54-
return job_cls(cmd, args, client_id)
56+
# For eval, preserve the remainder as a single string argument
57+
args = [remainder.strip()] if cmd == "eval" else remainder.split(" ")
58+
return job_cls(cmd, args, client_id, globals=globals)
5559

5660

5761
class SequentialJob(Job):
@@ -282,6 +286,39 @@ async def reboot_callback(op):
282286
return BytesIO(msg.encode("utf-8"))
283287

284288

289+
class RunPyJob(Job):
290+
"""A job to evaluate Python script on the device."""
291+
292+
argc = 1
293+
294+
def output(self):
295+
"""Eval or exec given Python and return the result."""
296+
expr = self.args[0]
297+
try:
298+
result = self.do_eval(expr)
299+
except SyntaxError: # Not an expression, try exec
300+
result = self.do_exec(expr)
301+
return BytesIO(result.encode("utf-8"))
302+
303+
def do_eval(self, expr):
304+
"""Evaluate a Python expression and return the result."""
305+
op = compile(expr, "<string>", "eval")
306+
result = eval(op, self.globals, None)
307+
return repr(result)
308+
309+
def do_exec(self, expr):
310+
"""Execute a Python statement and return the output."""
311+
out_buf = BytesIO()
312+
old_term = os.dupterm(out_buf)
313+
try:
314+
op = compile(expr, "<string>", "exec")
315+
exec(op, self.globals, None)
316+
result = out_buf.getvalue()
317+
finally:
318+
os.dupterm(old_term)
319+
return result
320+
321+
285322
# Map commands to associated job names
286323
COMMANDS = {
287324
"whoami": WhoAmIJob,
@@ -291,4 +328,5 @@ async def reboot_callback(op):
291328
"cp": PutFileJob,
292329
"ota": FirmwareUpdateJob,
293330
"reboot": RebootJob,
331+
"eval": RunPyJob,
294332
}

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: 21 additions & 0 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

@@ -230,3 +231,23 @@ def test_run_soft(self):
230231
job = RebootJob("reboot", ["soft"])
231232
output = job.output().read().decode("utf-8").strip()
232233
self.assertEqual(output, "Performing soft reboot")
234+
235+
236+
class TestRunPyJob(TestCase):
237+
def test_eval(self):
238+
"""RunPyJob should evaluate a Python expression and return result"""
239+
job = RunPyJob("eval", ["1 + 2"])
240+
output = job.output().read().decode("utf-8").strip()
241+
self.assertEqual(output, "3")
242+
243+
def test_globals(self):
244+
"""RunPyJob should use provided globals for evaluation"""
245+
job = RunPyJob("eval", ["x + y"], globals={"x": 1, "y": 2})
246+
output = job.output().read().decode("utf-8").strip()
247+
self.assertEqual(output, "3")
248+
249+
def test_exec(self):
250+
"""RunPyJob should execute a Python script and return output"""
251+
job = RunPyJob("eval", ['print("Hello, World!")'])
252+
output = job.output().read().decode("utf-8").strip()
253+
self.assertEqual(output, "Hello, World!")

0 commit comments

Comments
 (0)