diff --git a/CHANGELOG.md b/CHANGELOG.md index f515478..7bcd94e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/mytk/__main__.py b/mytk/__main__.py index 3356c6c..428473c 100644 --- a/mytk/__main__.py +++ b/mytk/__main__.py @@ -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 = [ diff --git a/mytk/remotecli.py b/mytk/remotecli.py new file mode 100644 index 0000000..f15d798 --- /dev/null +++ b/mytk/remotecli.py @@ -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() diff --git a/mytk/tests/testRemoteCLI.py b/mytk/tests/testRemoteCLI.py new file mode 100644 index 0000000..c9871a0 --- /dev/null +++ b/mytk/tests/testRemoteCLI.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index 005b4bf..28b3911 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"