Skip to content

Commit e2c1a4c

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

File tree

3 files changed

+127
-13
lines changed

3 files changed

+127
-13
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().strip()
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: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
PlatformInfoJob,
1515
PutFileJob,
1616
RebootJob,
17+
RunPyJob,
1718
WhoAmIJob,
1819
)
20+
from tests.utils import Mock
1921

2022

2123
class TestJob(TestCase):
@@ -28,6 +30,20 @@ def test_from_cmd(self):
2830
with self.assertRaises(ValueError):
2931
Job.from_cmd("unknown")
3032

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

218234
def tearDown(self):
219-
self.logger.setLevel(self.old_level)
235+
self.logger.handlers = self.handlers
220236

221237
def test_run_hard(self):
222238
"""Reboot job should signal and perform a hard reboot"""
@@ -230,3 +246,43 @@ def test_run_soft(self):
230246
job = RebootJob("reboot", ["soft"])
231247
output = job.output().read().decode("utf-8").strip()
232248
self.assertEqual(output, "Performing soft reboot")
249+
250+
251+
class TestRunPyJob(TestCase):
252+
def setUp(self):
253+
# Mock os.dupterm; not present in unix micropython
254+
self.mock_dupterm = Mock()
255+
try:
256+
setattr(os, "dupterm", self.mock_dupterm)
257+
except AttributeError: # raises in CI only??
258+
pass
259+
260+
def tearDown(self):
261+
# Remove mock
262+
if hasattr(os, "dupterm"):
263+
delattr(os, "dupterm")
264+
265+
def test_eval(self):
266+
"""RunPyJob should evaluate a Python expression and return result"""
267+
job = RunPyJob("eval", ["1 + 2"])
268+
output = job.output().read().decode("utf-8").strip()
269+
self.assertEqual(output, "3")
270+
271+
def test_globals(self):
272+
"""RunPyJob should use provided globals for evaluation"""
273+
job = RunPyJob("eval", ["x + y"], globals={"x": 1, "y": 2})
274+
output = job.output().read().decode("utf-8").strip()
275+
self.assertEqual(output, "3")
276+
277+
def test_exec(self):
278+
"""RunPyJob should execute a Python script with side effects"""
279+
cmd = """
280+
with open("output.txt", "w") as f:
281+
f.write("Hello, World!")
282+
""".strip()
283+
job = RunPyJob("exec", [cmd])
284+
job_output = job.output().read().decode("utf-8").strip()
285+
file_output = open("output.txt").read().strip()
286+
self.assertEqual(job_output, "") # No output expected
287+
self.assertEqual(file_output, "Hello, World!")
288+
os.remove("output.txt")

0 commit comments

Comments
 (0)