Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

All notable changes to myTk are documented here.

## [1.7.0]
### Added
- **Remote command-line client** for talking to a running
`RemoteControllable` app. Call a function and print its result with
`python -m mytk --remote "turn_on()"` or the standalone `mytk-remote`
console script (`mytk-remote "add(2, 3)" --port 9000`). `--list` prints the
exposed functions and their signatures; `--host`/`--port`/`--app-name`
select and verify the target. Arguments are parsed as Python literals with
`ast` (no code execution); a bare name means a no-argument call.

## [1.6.1]
### Added
- **`remote_command`** decorator and
Expand Down
10 changes: 10 additions & 0 deletions mytk/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ def printAllChilds(aClass): # noqa: N802, N803

def main():
"""Run the mytk command-line interface for examples, tests, and class inspection."""
# `--remote` switches to the remote client; hand the rest of the arguments
# to it (kept separate from the example-launcher parser below, which has its
# own -l/--list). Example: python -m mytk --remote "turn_on()" --port 9000
argv = sys.argv[1:]
if "--remote" in argv:
from .remotecli import run

rest = [argument for argument in argv if argument != "--remote"]
sys.exit(run(rest, prog="python -m mytk --remote"))

root = Path(__file__).parent
examples_dir = root / "example_apps"
examples = [
Expand Down
144 changes: 144 additions & 0 deletions mytk/remotecli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""remotecli.py — Command-line client for a RemoteControllable mytk app.

Send a single call to a running app that exposed functions with
:class:`~mytk.remotecontrollable.RemoteControllable` and print the result::

python -m mytk --remote "turn_on()"
python -m mytk --remote "set_power(2.5)" --port 9000
mytk-remote "add(2, 3)" # standalone console script
mytk-remote --app-name Acquisition "status()"
mytk-remote --list # show the exposed functions

Arguments in the call string must be Python literals (numbers, strings,
True/False/None, lists, dicts, tuples); a bare ``"turn_on"`` is treated as
``"turn_on()"``. Keyword arguments are not supported — XML-RPC carries
positional arguments only.
"""

import argparse
import ast
import sys
import xmlrpc.client

from .remote import DEFAULT_HOST, DEFAULT_PORT, RemoteAppMismatch, connect


def parse_command(command):
"""Parse a ``"name(arg, ...)"`` call string into ``(name, args)``.

Args:
command (str): A call such as ``"turn_on()"`` or ``"add(2, 3)"``. A
bare name (``"turn_on"``) is accepted and means a no-argument call.

Returns:
tuple[str, list]: The function name and its positional arguments.

Raises:
ValueError: If it is not a simple call of literal arguments, or uses
keyword arguments (unsupported over XML-RPC).
"""
try:
node = ast.parse(command.strip(), mode="eval").body
except SyntaxError as exc:
raise ValueError(f"Could not parse command {command!r}: {exc}") from exc

if isinstance(node, ast.Name):
return node.id, []
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name):
raise ValueError(f"Not a function call: {command!r}")
if node.keywords:
raise ValueError("Keyword arguments are not supported over XML-RPC")

try:
args = [ast.literal_eval(argument) for argument in node.args]
except (ValueError, SyntaxError) as exc:
raise ValueError(
f"Arguments must be literals (numbers, strings, lists, ...): {exc}"
) from exc
return node.func.id, args


def build_parser(prog=None):
"""Build the argument parser for the remote command-line client."""
parser = argparse.ArgumentParser(
prog=prog,
description="Call a function on a running RemoteControllable mytk app.",
)
parser.add_argument(
"command",
nargs="?",
help='function call, e.g. "turn_on()" or "add(2, 3)"',
)
parser.add_argument(
"--host", default=DEFAULT_HOST, help=f"server host (default: {DEFAULT_HOST})"
)
parser.add_argument(
"--port", type=int, default=DEFAULT_PORT,
help=f"server port (default: {DEFAULT_PORT})",
)
parser.add_argument(
"--app-name", default=None,
help="verify the server identifies as this name before calling",
)
parser.add_argument(
"--list", action="store_true",
help="list the exposed functions and their signatures, then exit",
)
return parser


def run(argv=None, prog=None):
"""Run one remote command-line invocation.

Args:
argv (list[str], optional): Arguments to parse (defaults to
``sys.argv[1:]``).
prog (str, optional): Program name shown in usage/errors.

Returns:
int: Process exit code (0 success, 1 runtime error, 2 usage/identity).
"""
parser = build_parser(prog)
args = parser.parse_args(argv)

if not args.list and not args.command:
parser.error("a command is required unless --list is given")

try:
proxy = connect(args.host, args.port, app_name=args.app_name)

if args.list:
signatures = proxy.remote_signatures()
for name in sorted(signatures):
print(f"{name}{signatures[name]}")
return 0

name, call_args = parse_command(args.command)
result = getattr(proxy, name)(*call_args)
if result is not None:
print(result)
return 0
except ValueError as exc: # bad command string
print(f"error: {exc}", file=sys.stderr)
return 2
except RemoteAppMismatch as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
except xmlrpc.client.Fault as exc:
print(f"error: remote call failed: {exc.faultString}", file=sys.stderr)
return 1
except (ConnectionError, OSError) as exc:
print(
f"error: could not reach a mytk app at {args.host}:{args.port} ({exc})",
file=sys.stderr,
)
return 1


def main():
"""Console-script entry point (``mytk-remote``)."""
raise SystemExit(run(prog="mytk-remote"))


if __name__ == "__main__":
main()
106 changes: 106 additions & 0 deletions mytk/tests/testRemoteCLI.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import contextlib
import io
import threading
import unittest

from mytk import App, RemoteControllable, remote_command
from mytk.remotecli import parse_command, run


class TestParseCommand(unittest.TestCase):
"""Pure parsing of the "name(args)" call string."""

def test_no_args(self):
self.assertEqual(parse_command("turn_on()"), ("turn_on", []))

def test_bare_name(self):
self.assertEqual(parse_command("turn_on"), ("turn_on", []))

def test_positional_literals(self):
self.assertEqual(parse_command("add(2, 3)"), ("add", [2, 3]))
self.assertEqual(parse_command("greet('bob')"), ("greet", ["bob"]))
self.assertEqual(
parse_command("configure([1, 2], {'k': True})"),
("configure", [[1, 2], {"k": True}]),
)

def test_keyword_args_rejected(self):
with self.assertRaises(ValueError):
parse_command("f(a=1)")

def test_non_call_rejected(self):
with self.assertRaises(ValueError):
parse_command("1 + 1")

def test_non_literal_args_rejected(self):
with self.assertRaises(ValueError):
parse_command("f(some_variable)")


class CLIApp(App, RemoteControllable):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.flag = False

@remote_command
def flip(self):
self.flag = True
return self.flag


class TestRemoteCLI(unittest.TestCase):
"""End-to-end: the CLI drives a live server over a real socket."""

def setUp(self):
self.app = CLIApp(name=self.id())
self.app.register_remote_commands()
self.rc = None
self.output = None

def tearDown(self):
self.app.quit()

def _run_cli(self, argv, timeout=1500):
def client_call():
buffer = io.StringIO()
with contextlib.redirect_stdout(buffer):
self.rc = run(argv)
self.output = buffer.getvalue()

thread = threading.Thread(target=client_call)
self.app.after(int(timeout / 4), thread.start)
self.app.after(timeout, self.app.quit)
self.app.mainloop()
thread.join(timeout=3)

def test_call_prints_result(self):
port = self.app.start_remote(port=0)
self._run_cli(["flip()", "--port", str(port)])
self.assertEqual(self.rc, 0)
self.assertEqual(self.output.strip(), "True")
self.assertTrue(self.app.flag)

def test_list_shows_signatures(self):
port = self.app.start_remote(port=0)
self._run_cli(["--list", "--port", str(port)])
self.assertEqual(self.rc, 0)
self.assertIn("flip()", self.output)

def test_app_name_mismatch_exits_nonzero(self):
port = self.app.start_remote(port=0, app_name="Real")
self._run_cli(["flip()", "--port", str(port), "--app-name", "Wrong"])
self.assertEqual(self.rc, 2)
self.assertFalse(self.app.flag)


class TestRemoteCLIOffline(unittest.TestCase):
"""Errors that do not need a running server."""

def test_connection_refused_exits_1(self):
# Nothing is listening on this port.
rc = run(["flip()", "--port", "1"])
self.assertEqual(rc, 1)


if __name__ == "__main__":
unittest.main()
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ classifiers = [
"Programming Language :: Python :: 3.13",
]

[project.scripts]
mytk-remote = "mytk.remotecli:main"

[project.urls]
Homepage = "https://github.com/DCC-Lab/myTk"
Repository = "https://github.com/DCC-Lab/myTk"
Expand Down
Loading