From c6e835d55b0d89eb4abbbdcf2ddb4ff4652cef2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=C3=B4t=C3=A9?= Date: Tue, 7 Jul 2026 00:59:54 -0400 Subject: [PATCH] feat: start_remote() auto-registers @remote_command methods start_remote() now calls register_remote_commands() before publishing, so tagged methods just work without a separate call. The explicit method remains available and idempotent. Docs now present @remote_command as the primary way to expose methods and frame app.remote(...) as the low-level primitive it builds on (kept for free functions / dynamic registration). No behavior removed; fully backward compatible. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 9 +++++ mytk/remotecontrollable.py | 71 ++++++++++++++++++------------------- mytk/tests/testRemote.py | 7 ++++ mytk/tests/testRemoteCLI.py | 3 +- 4 files changed, 51 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bcd94e..8b4cf85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to myTk are documented here. +## [1.7.1] +### Changed +- **`start_remote()` now auto-registers `@remote_command` methods**, so you no + longer need to call `register_remote_commands()` yourself — tagged methods + just work. The explicit call remains available and idempotent. `@remote_command` + is now the primary way to expose methods; `app.remote(...)` is documented as + the low-level primitive it builds on (still available for free functions or + dynamic registration). + ## [1.7.0] ### Added - **Remote command-line client** for talking to a running diff --git a/mytk/remotecontrollable.py b/mytk/remotecontrollable.py index d64e55f..5e286dc 100644 --- a/mytk/remotecontrollable.py +++ b/mytk/remotecontrollable.py @@ -1,25 +1,9 @@ """remotecontrollable.py — Capability mixin exposing an App over RPC. `RemoteControllable` is a capability mixin, in the same spirit as -`EventCapable` and `DragAndDropCapable`. Mix it into an `App` subclass to let -external processes call selected functions on the running application:: - - from mytk import App, RemoteControllable - - class MyApp(App, RemoteControllable): - pass - - app = MyApp(name="Server") - - @app.remote - def add(a, b): - return a + b - - app.start_remote() # localhost:8777 - app.mainloop() - -Methods declared in a class body cannot use ``@app.remote`` (there is no live -app yet); tag them with ``@remote_command`` and register them in one call:: +`EventCapable` and `DragAndDropCapable`. Mix it into an `App` subclass and tag +the methods you want to expose with ``@remote_command``; ``start_remote()`` +publishes them so external processes can call them:: from mytk import App, RemoteControllable, remote_command @@ -33,13 +17,19 @@ def read_status(self): return {"on": True} app = MyApp(name="Server") - app.register_remote_commands() # expose every tagged method - app.start_remote() + app.start_remote() # localhost:8777 — tagged methods are auto-registered app.mainloop() Clients connect with ``mytk.connect(...)`` or ``mytk.remote_app`` (see -:mod:`mytk.remote`). The transport is stdlib XML-RPC, so arguments and return -values must be XML-RPC serializable (numbers, str, bool, None, list, dict). +:mod:`mytk.remote`), or from the command line with ``mytk-remote`` / +``python -m mytk --remote`` (see :mod:`mytk.remotecli`). The transport is stdlib +XML-RPC, so arguments and return values must be XML-RPC serializable (numbers, +str, bool, None, list, dict). + +For a free function, or to register something dynamically at runtime, the +low-level primitive ``app.remote(fct, name=...)`` registers it directly; +``@remote_command`` is just a class-body-friendly marker that ``start_remote`` +feeds to it. The host class must provide ``schedule_on_main_thread`` and ``root`` (both are supplied by `App`): every call is marshaled onto the Tk main thread, so exposed @@ -102,17 +92,19 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # cooperative! def remote(self, fct=None, *, name=None): - """Exposes a function for remote invocation by clients. + """Low-level primitive: expose a function for remote invocation. - Usable as a decorator (``@app.remote``) or directly - (``app.remote(fct)``). Only exposed functions are reachable. The call - runs on the Tk main thread, so it may safely touch widgets. Arguments - and the return value must be XML-RPC serializable (numbers, str, bool, - None, list, dict). + Most code should prefer :func:`remote_command` on methods; this is the + underlying call it feeds (and what to use for a free function or dynamic + registration at runtime). Usable directly (``app.remote(fct)``) or as a + decorator. Only exposed functions are reachable. The call runs on the Tk + main thread, so it may safely touch widgets. Arguments and the return + value must be XML-RPC serializable (numbers, str, bool, None, list, + dict). Args: fct (callable, optional): The function to expose. Omitted when used - as a bare ``@app.remote`` decorator. + as a bare decorator. name (str, optional): Name clients use to call it. Defaults to the function's own name. @@ -129,8 +121,9 @@ def register_remote_commands(self): Scans the class (not the instance, so property getters are never triggered) for methods carrying the ``@remote_command`` marker and - registers each with :meth:`remote`. Call once, before or after - :meth:`start_remote`. + registers each with :meth:`remote`. Called automatically by + :meth:`start_remote`, so you rarely need to call it yourself; doing so + is safe and idempotent. Returns: list[str]: The RPC names that were registered. @@ -175,12 +168,14 @@ def remote_app_name(self): return self.app_name def start_remote(self, port=8777, host="127.0.0.1", app_name=None): - """Starts a background server exposing the functions registered with - :meth:`remote`. + """Starts a background server exposing the app's remote functions. - Localhost-only by default. The server runs in a daemon thread; each - call is marshaled onto the Tk main thread and its return value sent - back to the client. Safe to call once; further calls are no-ops. + Any method tagged with :func:`remote_command` is registered first (via + :meth:`register_remote_commands`), so tagged methods just work without a + separate call. Localhost-only by default. The server runs in a daemon + thread; each call is marshaled onto the Tk main thread and its return + value sent back to the client. Safe to call once; further calls are + no-ops. Args: port (int): Port to bind. Use 0 to let the OS pick a free port. @@ -203,6 +198,8 @@ def start_remote(self, port=8777, host="127.0.0.1", app_name=None): if self.app_name is None: self.app_name = getattr(self, "name", None) + self.register_remote_commands() + server = SimpleXMLRPCServer( (host, port), allow_none=True, logRequests=False ) diff --git a/mytk/tests/testRemote.py b/mytk/tests/testRemote.py index eb0f520..99e0e88 100644 --- a/mytk/tests/testRemote.py +++ b/mytk/tests/testRemote.py @@ -262,6 +262,13 @@ def test_register_scans_class_without_triggering_properties(self): names = self.app.register_remote_commands() self.assertEqual(set(names), {"flip", "status"}) + def test_start_remote_auto_registers_commands(self): + # No explicit register_remote_commands() call — start_remote does it. + self.app.start_remote(port=0) + signatures = self.app.remote_signatures() + self.assertIn("flip", signatures) + self.assertIn("status", signatures) + def test_bare_command_uses_method_name(self): self.app.register_remote_commands() self.assertIn("flip", self.app.remote_signatures()) diff --git a/mytk/tests/testRemoteCLI.py b/mytk/tests/testRemoteCLI.py index c9871a0..376d770 100644 --- a/mytk/tests/testRemoteCLI.py +++ b/mytk/tests/testRemoteCLI.py @@ -52,8 +52,7 @@ 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.app = CLIApp(name=self.id()) # start_remote auto-registers commands self.rc = None self.output = None