From 89924c39b0e2ba418a545881f2ba781eb8516440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=C3=B4t=C3=A9?= Date: Tue, 7 Jul 2026 09:54:15 -0400 Subject: [PATCH 1/2] Add mDNS/Bonjour discovery for remote apps (no hard-coded ports) Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 20 +++ mytk/__init__.py | 3 +- mytk/remote.py | 86 ++++++++++++ mytk/remotecli.py | 56 +++++++- mytk/remotecontrollable.py | 92 ++++++++++++- mytk/tests/testRemoteDiscovery.py | 218 ++++++++++++++++++++++++++++++ pyproject.toml | 3 +- 7 files changed, 469 insertions(+), 9 deletions(-) create mode 100644 mytk/tests/testRemoteDiscovery.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b4cf85..99a86dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to myTk are documented here. +## [1.8.0] +### Added +- **Network discovery for remote apps (mDNS/Bonjour), so ports no longer need + to be hard-coded.** A `RemoteControllable` app can publish itself on the + local network with `app.advertise_remote()`, which starts the remote server + on an OS-picked free port (`port=0`) and announces it over mDNS. The app's + `app_name` becomes the service instance name and is placed in the TXT record. +- **`mytk.discover(app_name=None, service_type="_mytk._tcp.local.", timeout=3.0)`** + finds an advertised server on the network and returns a proxy via the existing + `connect()` (so `RemoteAppMismatch` verification still applies). `app_name` + filters by the advertised identity. +- **`mytk-remote --discover`** locates the server over mDNS instead of using + `--host`/`--port`; `--app-name` selects which advertised app to use, and + `--service-type`/`--timeout` tune the browse. Explicit `--host`/`--port` + behavior is unchanged. +- New optional dependency extra `mytk[remote]` (installs `zeroconf`). As with + other optional features, `zeroconf` is also installed on demand at first use + of `advertise_remote()`; `discover()` imports it directly and raises a clear + `ImportError` if missing (a client is often a plain script with no Tk root). + ## [1.7.1] ### Changed - **`start_remote()` now auto-registers `@remote_command` methods**, so you no diff --git a/mytk/__init__.py b/mytk/__init__.py index 2babafa..db88a5d 100644 --- a/mytk/__init__.py +++ b/mytk/__init__.py @@ -39,7 +39,7 @@ from .popupmenu import PopupMenu from .progressbar import ProgressBar, ProgressBarNotification, ProgressWindow from .radiobutton import RadioButton -from .remote import RemoteAppMismatch, connect, remote_app +from .remote import RemoteAppMismatch, connect, discover, remote_app from .remotecontrollable import RemoteControllable, remote_command from .tableview import TableView from .tabulardata import PostponeChangeCalls, TabularData @@ -105,6 +105,7 @@ "Window", "XYPlot", "connect", + "discover", "remote_app", "remote_command", "tkFont", diff --git a/mytk/remote.py b/mytk/remote.py index 5ac3971..de468f5 100644 --- a/mytk/remote.py +++ b/mytk/remote.py @@ -65,6 +65,92 @@ def connect(host=DEFAULT_HOST, port=DEFAULT_PORT, app_name=None): return proxy +DEFAULT_SERVICE_TYPE = "_mytk._tcp.local." + + +def discover(app_name=None, service_type=DEFAULT_SERVICE_TYPE, timeout=3.0): + """Find a myTk remote server on the local network and connect to it. + + Browses for services advertised by :meth:`~mytk.remotecontrollable.RemoteControllable.advertise_remote` + and returns a proxy to the first match, so callers never need a hard-coded + host or port:: + + far = mytk.discover(app_name="Microscope") + far.turn_on() + + Requires the optional ``zeroconf`` package. Unlike the server side, this is + imported directly (not through ``ModulesManager``) because a client is often + a plain script with no Tk root, so a dialog-based installer would be wrong + here; a clear ImportError is raised instead if the package is missing. + + Args: + app_name (str, optional): If given, only match a server advertising this + name (via its TXT record), and verify identity on connect. If None, + the first server found for ``service_type`` is used. + service_type (str): DNS-SD service type to browse. Must match what the + server advertised. + timeout (float): Seconds to wait for an advertisement before giving up. + + Returns: + xmlrpc.client.ServerProxy: A proxy to the discovered server, as returned + by :func:`connect`. + + Raises: + ImportError: If the ``zeroconf`` package is not installed. + TimeoutError: If no matching service appears within ``timeout``. + """ + import time + + try: + from zeroconf import ServiceBrowser, Zeroconf + except ImportError as err: + raise ImportError( + "discover() needs the 'zeroconf' package. Install it with " + "'pip install zeroconf'." + ) from err + + found = {} + + class _Listener: + def add_service(self, zc, type_, name): + info = zc.get_service_info(type_, name) + if info is not None: + found[name] = info + + def update_service(self, zc, type_, name): + info = zc.get_service_info(type_, name) + if info is not None: + found[name] = info + + def remove_service(self, zc, type_, name): + found.pop(name, None) + + zc = Zeroconf() + ServiceBrowser(zc, service_type, _Listener()) + try: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + for info in list(found.values()): + properties = { + key.decode(): (value or b"").decode() + for key, value in info.properties.items() + } + if app_name is not None and properties.get("app") != app_name: + continue + addresses = info.parsed_addresses() + if not addresses: + continue + return connect(addresses[0], info.port, app_name) + time.sleep(0.05) + finally: + zc.close() + + target = f" for app {app_name!r}" if app_name is not None else "" + raise TimeoutError( + f"No {service_type} server found{target} within {timeout}s" + ) + + class RemoteAppProxy: """Module-level proxy that connects on first use. diff --git a/mytk/remotecli.py b/mytk/remotecli.py index f15d798..dd83858 100644 --- a/mytk/remotecli.py +++ b/mytk/remotecli.py @@ -9,6 +9,13 @@ mytk-remote --app-name Acquisition "status()" mytk-remote --list # show the exposed functions +Instead of a fixed ``--host``/``--port``, the server can be located on the +local network via mDNS/Bonjour (as published by ``advertise_remote``):: + + mytk-remote --discover "turn_on()" + mytk-remote --discover --app-name Microscope "status()" + mytk-remote --discover --list + 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 @@ -20,7 +27,14 @@ import sys import xmlrpc.client -from .remote import DEFAULT_HOST, DEFAULT_PORT, RemoteAppMismatch, connect +from .remote import ( + DEFAULT_HOST, + DEFAULT_PORT, + DEFAULT_SERVICE_TYPE, + RemoteAppMismatch, + connect, + discover, +) def parse_command(command): @@ -78,7 +92,22 @@ def build_parser(prog=None): ) parser.add_argument( "--app-name", default=None, - help="verify the server identifies as this name before calling", + help="verify the server identifies as this name before calling; " + "with --discover, also selects which advertised app to use", + ) + parser.add_argument( + "--discover", action="store_true", + help="find the server on the local network via mDNS instead of " + "using --host/--port", + ) + parser.add_argument( + "--service-type", default=DEFAULT_SERVICE_TYPE, + help=f"mDNS service type to browse with --discover " + f"(default: {DEFAULT_SERVICE_TYPE})", + ) + parser.add_argument( + "--timeout", type=float, default=3.0, + help="seconds to wait for a service with --discover (default: 3.0)", ) parser.add_argument( "--list", action="store_true", @@ -105,7 +134,14 @@ def run(argv=None, prog=None): parser.error("a command is required unless --list is given") try: - proxy = connect(args.host, args.port, app_name=args.app_name) + if args.discover: + proxy = discover( + app_name=args.app_name, + service_type=args.service_type, + timeout=args.timeout, + ) + else: + proxy = connect(args.host, args.port, app_name=args.app_name) if args.list: signatures = proxy.remote_signatures() @@ -124,14 +160,22 @@ def run(argv=None, prog=None): except RemoteAppMismatch as exc: print(f"error: {exc}", file=sys.stderr) return 2 + except ImportError as exc: # --discover without the zeroconf package + print(f"error: {exc}", file=sys.stderr) + return 2 + except TimeoutError as exc: # --discover found nothing in time + print(f"error: {exc}", file=sys.stderr) + return 1 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, + target = ( + "the discovered mytk app" + if args.discover + else f"a mytk app at {args.host}:{args.port}" ) + print(f"error: could not reach {target} ({exc})", file=sys.stderr) return 1 diff --git a/mytk/remotecontrollable.py b/mytk/remotecontrollable.py index 5e286dc..ce61e5c 100644 --- a/mytk/remotecontrollable.py +++ b/mytk/remotecontrollable.py @@ -89,6 +89,8 @@ def __init__(self, *args, **kwargs): self.remote_server = None self.remote_call_timeout = 30 self.app_name = None + self._zeroconf = None + self._remote_service_info = None super().__init__(*args, **kwargs) # cooperative! def remote(self, fct=None, *, name=None): @@ -226,8 +228,96 @@ def start_remote(self, port=8777, host="127.0.0.1", app_name=None): self.root.bind("", self.stop_remote_on_destroy, add="+") return server.server_address[1] + @staticmethod + def _lan_ip(): + """Best-effort local network IP for the interface used to reach the LAN. + + Opens a UDP socket toward a public address to learn which local + interface the OS would route through, without sending any packet. Falls + back to loopback if no route is available (e.g. offline machine). + """ + import socket + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + sock.connect(("8.8.8.8", 80)) # no packet is actually sent + return sock.getsockname()[0] + except OSError: + return "127.0.0.1" + finally: + sock.close() + + def advertise_remote( + self, + port=0, + host="0.0.0.0", + service_type="_mytk._tcp.local.", + addr=None, + ): + """Start the remote server and announce it on the local network via mDNS. + + Combines :meth:`start_remote` with a Zeroconf/Bonjour announcement so + clients can find the app with :func:`mytk.discover` instead of a + hard-coded port. The OS-picked port (``port=0``) is what gets + advertised, so ports never need to be fixed in advance. + + The app's identity (:attr:`app_name`) becomes the service instance name + and is also placed in the TXT record, so :func:`mytk.discover` can target + a specific app when several are on the network. + + Requires the optional ``zeroconf`` package; it is installed on demand + through :class:`ModulesManager` (a Tk app context is assumed here, as for + any :class:`RemoteControllable`). + + Args: + port (int): Port to bind. Defaults to 0, letting the OS choose a free + one — the recommended mode, since the chosen port is advertised. + host (str): Interface the server binds. Defaults to all interfaces + (``0.0.0.0``) so the service is reachable from other machines; + pass ``127.0.0.1`` to keep it local (then discovery is + same-machine only). + service_type (str): DNS-SD service type. Describes the application + protocol (myTk/XML-RPC), not the transport. + addr (str, optional): IP address to advertise. Defaults to the + autodetected LAN address (see :meth:`_lan_ip`). Override when the + autodetected interface is not the one clients should use. + + Returns: + int: The port actually bound and advertised. + """ + import socket + + from .modulesmanager import ModulesManager + + ModulesManager.install_and_import_modules_if_absent({"zeroconf": "zeroconf"}) + zeroconf = ModulesManager.imported["zeroconf"] + + port = self.start_remote(port=port, host=host) + + name = self.app_name or getattr(self, "name", None) or "myTk" + ip = addr or self._lan_ip() + info = zeroconf.ServiceInfo( + service_type, + f"{name}.{service_type}", + addresses=[socket.inet_aton(ip)], + port=port, + properties={"app": name, "path": "/"}, + ) + self._zeroconf = zeroconf.Zeroconf() + self._zeroconf.register_service(info) + self._remote_service_info = info + return port + def stop_remote(self): - """Shuts down the remote server if it is running.""" + """Shuts down the remote server and withdraws any mDNS announcement.""" + if self._zeroconf is not None: + zc, self._zeroconf = self._zeroconf, None + info, self._remote_service_info = self._remote_service_info, None + with suppress(Exception): + if info is not None: + zc.unregister_service(info) + with suppress(Exception): + zc.close() if self.remote_server is not None: server, self.remote_server = self.remote_server, None with suppress(Exception): diff --git a/mytk/tests/testRemoteDiscovery.py b/mytk/tests/testRemoteDiscovery.py new file mode 100644 index 0000000..62c3f96 --- /dev/null +++ b/mytk/tests/testRemoteDiscovery.py @@ -0,0 +1,218 @@ +"""Tests for network discovery of remote apps (mytk.discover and --discover). + +These are hermetic: a fake ``zeroconf`` module stands in for real mDNS, so no +multicast traffic, sockets, or Tk window are needed. End-to-end socket behavior +of the remote server itself is covered by testRemote.py / testRemoteCLI.py. +""" + +import contextlib +import io +import sys +import types +import unittest +from unittest import mock + +from mytk import remote +from mytk.remotecli import run + + +def _make_fake_zeroconf(services): + """Build a stand-in ``zeroconf`` module advertising the given services. + + Args: + services (list[tuple[str, str, str, int, dict]]): Each entry is + ``(service_type, instance_name, ip, port, properties)`` where + ``properties`` maps str keys to str values (as an app would pass). + + Returns: + types.ModuleType: A module exposing ``ServiceInfo``, ``Zeroconf`` and + ``ServiceBrowser`` compatible with how ``discover()`` uses them. + """ + + class FakeServiceInfo: + def __init__(self, type_, name, ip, port, properties): + self.type = type_ + self.name = name + self._ip = ip + self.port = port + self.properties = { + key.encode(): value.encode() for key, value in properties.items() + } + + def parsed_addresses(self): + return [self._ip] + + infos = [ + FakeServiceInfo(service_type, name, ip, port, properties) + for service_type, name, ip, port, properties in services + ] + + class FakeZeroconf: + def get_service_info(self, type_, name): + for info in infos: + if info.type == type_ and info.name == name: + return info + return None + + def close(self): + pass + + class FakeServiceBrowser: + def __init__(self, zc, type_, listener): + for info in infos: + if info.type == type_: + listener.add_service(zc, type_, info.name) + + module = types.ModuleType("zeroconf") + module.ServiceInfo = FakeServiceInfo + module.Zeroconf = FakeZeroconf + module.ServiceBrowser = FakeServiceBrowser + return module + + +class TestDiscover(unittest.TestCase): + """mytk.discover() browsing behavior, with real connect() stubbed out.""" + + SERVICE_TYPE = "_mytk._tcp.local." + + def _install_zeroconf(self, services): + fake = _make_fake_zeroconf(services) + patcher = mock.patch.dict(sys.modules, {"zeroconf": fake}) + patcher.start() + self.addCleanup(patcher.stop) + + def _capture_connect(self): + # discover() ends by calling remote.connect(host, port, app_name); capture + # its arguments and hand back a sentinel instead of opening a socket. + calls = [] + + def fake_connect(host, port, app_name=None): + calls.append((host, port, app_name)) + return f"proxy://{host}:{port}" + + patcher = mock.patch.object(remote, "connect", fake_connect) + patcher.start() + self.addCleanup(patcher.stop) + return calls + + def test_discover_connects_to_advertised_server(self): + self._install_zeroconf( + [(self.SERVICE_TYPE, "Microscope." + self.SERVICE_TYPE, + "192.168.1.42", 54321, {"app": "Microscope", "path": "/"})] + ) + calls = self._capture_connect() + + proxy = remote.discover(timeout=1.0) + + self.assertEqual(proxy, "proxy://192.168.1.42:54321") + self.assertEqual(calls, [("192.168.1.42", 54321, None)]) + + def test_discover_filters_by_app_name(self): + self._install_zeroconf([ + (self.SERVICE_TYPE, "A." + self.SERVICE_TYPE, + "192.168.1.10", 1111, {"app": "A"}), + (self.SERVICE_TYPE, "B." + self.SERVICE_TYPE, + "192.168.1.20", 2222, {"app": "B"}), + ]) + calls = self._capture_connect() + + remote.discover(app_name="B", timeout=1.0) + + # Only B's endpoint is used, and its identity is passed to connect(). + self.assertEqual(calls, [("192.168.1.20", 2222, "B")]) + + def test_discover_times_out_when_no_service(self): + self._install_zeroconf([]) + self._capture_connect() + + with self.assertRaises(TimeoutError): + remote.discover(timeout=0.2) + + def test_discover_times_out_when_app_name_absent(self): + self._install_zeroconf( + [(self.SERVICE_TYPE, "A." + self.SERVICE_TYPE, + "192.168.1.10", 1111, {"app": "A"})] + ) + self._capture_connect() + + with self.assertRaises(TimeoutError): + remote.discover(app_name="B", timeout=0.2) + + def test_discover_without_zeroconf_raises_importerror(self): + # Setting the entry to None makes `from zeroconf import ...` raise. + patcher = mock.patch.dict(sys.modules, {"zeroconf": None}) + patcher.start() + self.addCleanup(patcher.stop) + + with self.assertRaises(ImportError): + remote.discover(timeout=0.2) + + +class TestRemoteCLIDiscovery(unittest.TestCase): + """`mytk-remote --discover` wiring, with discover() itself stubbed out.""" + + class FakeProxy: + def remote_signatures(self): + return {"add": "(a, b)", "flip": "()"} + + def add(self, a, b): + return a + b + + def _run(self, argv): + out = io.StringIO() + err = io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = run(argv, prog="mytk-remote") + return code, out.getvalue(), err.getvalue() + + def test_discover_list(self): + with mock.patch( + "mytk.remotecli.discover", return_value=self.FakeProxy() + ) as discover: + code, out, _ = self._run(["--discover", "--list"]) + self.assertEqual(code, 0) + self.assertIn("add(a, b)", out) + self.assertIn("flip()", out) + discover.assert_called_once() + + def test_discover_call(self): + with mock.patch("mytk.remotecli.discover", return_value=self.FakeProxy()): + code, out, _ = self._run(["--discover", "add(2, 3)"]) + self.assertEqual(code, 0) + self.assertEqual(out.strip(), "5") + + def test_discover_passes_app_name_and_options(self): + with mock.patch( + "mytk.remotecli.discover", return_value=self.FakeProxy() + ) as discover: + self._run([ + "--discover", "--app-name", "Microscope", + "--service-type", "_custom._tcp.local.", "--timeout", "1.5", + "--list", + ]) + discover.assert_called_once_with( + app_name="Microscope", + service_type="_custom._tcp.local.", + timeout=1.5, + ) + + def test_discover_timeout_reports_error(self): + with mock.patch( + "mytk.remotecli.discover", side_effect=TimeoutError("nothing found") + ): + code, _, err = self._run(["--discover", "flip()"]) + self.assertEqual(code, 1) + self.assertIn("nothing found", err) + + def test_discover_missing_zeroconf_reports_error(self): + with mock.patch( + "mytk.remotecli.discover", + side_effect=ImportError("needs the 'zeroconf' package"), + ): + code, _, err = self._run(["--discover", "flip()"]) + self.assertEqual(code, 2) + self.assertIn("zeroconf", err) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyproject.toml b/pyproject.toml index 28b3911..4050949 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,11 +50,12 @@ video = ["opencv-python"] # VideoView (heavy) view3d = ["trimesh", "moderngl", "numpy", "Pillow"] # View3D (needs an OpenGL context) dnd = ["tkinterdnd2"] # drag-and-drop (tkdnd; Tcl-version sensitive) svg = ["resvg-py"] # SVGImage (self-contained cross-platform wheel) +remote = ["zeroconf"] # discover()/advertise_remote() (mDNS/Bonjour) optics = ["raytracing"] docs = ["furo", "sphinx", "myst-parser"] screenshot = ["pyobjc-framework-Quartz; sys_platform == 'darwin'"] # `make screenshot` # Everything optional, in one go: -all = ["mytk[video,view3d,dnd,svg,optics]"] +all = ["mytk[video,view3d,dnd,svg,remote,optics]"] [tool.setuptools_scm] write_to = "mytk/_version.py" From eb60903c0d830176f16e8c893adc7a2ab921f4ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=C3=B4t=C3=A9?= Date: Tue, 7 Jul 2026 11:39:35 -0400 Subject: [PATCH 2/2] Add mytk.browse() and mytk-remote --browse to list all advertised apps List every myTk app advertised on the LAN (name + address) without connecting to any, complementing --discover which targets one. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 ++ mytk/__init__.py | 3 +- mytk/remote.py | 83 +++++++++++++++++++++++++++++++ mytk/remotecli.py | 19 ++++++- mytk/tests/testRemoteDiscovery.py | 67 +++++++++++++++++++++++++ 5 files changed, 173 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99a86dd..f652014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ All notable changes to myTk are documented here. `--host`/`--port`; `--app-name` selects which advertised app to use, and `--service-type`/`--timeout` tune the browse. Explicit `--host`/`--port` behavior is unchanged. +- **`mytk.browse(service_type="_mytk._tcp.local.", timeout=3.0)` and + `mytk-remote --browse`** list *every* myTk app advertised on the local + network (name and address) without connecting to any of them — handy for + seeing what is running before targeting one with `--discover --app-name`. - New optional dependency extra `mytk[remote]` (installs `zeroconf`). As with other optional features, `zeroconf` is also installed on demand at first use of `advertise_remote()`; `discover()` imports it directly and raises a clear diff --git a/mytk/__init__.py b/mytk/__init__.py index db88a5d..4e8dcef 100644 --- a/mytk/__init__.py +++ b/mytk/__init__.py @@ -39,7 +39,7 @@ from .popupmenu import PopupMenu from .progressbar import ProgressBar, ProgressBarNotification, ProgressWindow from .radiobutton import RadioButton -from .remote import RemoteAppMismatch, connect, discover, remote_app +from .remote import RemoteAppMismatch, browse, connect, discover, remote_app from .remotecontrollable import RemoteControllable, remote_command from .tableview import TableView from .tabulardata import PostponeChangeCalls, TabularData @@ -104,6 +104,7 @@ "View3DPyrender", "Window", "XYPlot", + "browse", "connect", "discover", "remote_app", diff --git a/mytk/remote.py b/mytk/remote.py index de468f5..34e50c1 100644 --- a/mytk/remote.py +++ b/mytk/remote.py @@ -151,6 +151,89 @@ def remove_service(self, zc, type_, name): ) +def browse(service_type=DEFAULT_SERVICE_TYPE, timeout=3.0): + """List every myTk remote server advertised on the local network. + + Unlike :func:`discover`, which connects to the first match, this collects + every service seen within ``timeout`` and returns their addresses *without* + connecting, so a caller (or ``mytk-remote --browse``) can show what is + running:: + + for server in mytk.browse(): + print(server["app"], server["host"], server["port"]) + + Requires the optional ``zeroconf`` package, imported directly (as in + :func:`discover`) so a plain client script gets a clear ImportError rather + than a Tk install dialog. + + Args: + service_type (str): DNS-SD service type to browse. Must match what the + servers advertised. + timeout (float): Seconds to collect advertisements before returning. + + Returns: + list[dict]: One entry per server, sorted by advertised name then + address, each with keys ``"app"`` (advertised name, or None), ``"host"`` + (IP address), ``"port"`` (int), and ``"service"`` (the raw mDNS instance + name). + + Raises: + ImportError: If the ``zeroconf`` package is not installed. + """ + import time + + try: + from zeroconf import ServiceBrowser, Zeroconf + except ImportError as err: + raise ImportError( + "browse() needs the 'zeroconf' package. Install it with " + "'pip install zeroconf'." + ) from err + + found = {} + + class _Listener: + def add_service(self, zc, type_, name): + info = zc.get_service_info(type_, name) + if info is not None: + found[name] = info + + def update_service(self, zc, type_, name): + info = zc.get_service_info(type_, name) + if info is not None: + found[name] = info + + def remove_service(self, zc, type_, name): + found.pop(name, None) + + zc = Zeroconf() + ServiceBrowser(zc, service_type, _Listener()) + try: + time.sleep(timeout) # collect for the whole window, don't stop early + finally: + zc.close() + + servers = [] + for name, info in found.items(): + addresses = info.parsed_addresses() + if not addresses: + continue + properties = { + key.decode(): (value or b"").decode() + for key, value in info.properties.items() + } + servers.append( + { + "app": properties.get("app"), + "host": addresses[0], + "port": info.port, + "service": name, + } + ) + servers.sort(key=lambda server: (server["app"] or "", server["host"], server["port"])) + return servers + + class RemoteAppProxy: """Module-level proxy that connects on first use. diff --git a/mytk/remotecli.py b/mytk/remotecli.py index dd83858..b27f725 100644 --- a/mytk/remotecli.py +++ b/mytk/remotecli.py @@ -15,6 +15,7 @@ mytk-remote --discover "turn_on()" mytk-remote --discover --app-name Microscope "status()" mytk-remote --discover --list + mytk-remote --browse # list all apps on the network Arguments in the call string must be Python literals (numbers, strings, True/False/None, lists, dicts, tuples); a bare ``"turn_on"`` is treated as @@ -32,6 +33,7 @@ DEFAULT_PORT, DEFAULT_SERVICE_TYPE, RemoteAppMismatch, + browse, connect, discover, ) @@ -100,6 +102,11 @@ def build_parser(prog=None): help="find the server on the local network via mDNS instead of " "using --host/--port", ) + parser.add_argument( + "--browse", action="store_true", + help="list every myTk app advertised on the local network " + "(name and address), then exit; uses --service-type/--timeout", + ) parser.add_argument( "--service-type", default=DEFAULT_SERVICE_TYPE, help=f"mDNS service type to browse with --discover " @@ -130,10 +137,18 @@ def run(argv=None, prog=None): 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") + if not args.list and not args.browse and not args.command: + parser.error("a command is required unless --list or --browse is given") try: + if args.browse: + for server in browse( + service_type=args.service_type, timeout=args.timeout + ): + label = server["app"] or server["service"] + print(f"{label}\t{server['host']}:{server['port']}") + return 0 + if args.discover: proxy = discover( app_name=args.app_name, diff --git a/mytk/tests/testRemoteDiscovery.py b/mytk/tests/testRemoteDiscovery.py index 62c3f96..4e01cd0 100644 --- a/mytk/tests/testRemoteDiscovery.py +++ b/mytk/tests/testRemoteDiscovery.py @@ -148,6 +148,47 @@ def test_discover_without_zeroconf_raises_importerror(self): remote.discover(timeout=0.2) +class TestBrowse(unittest.TestCase): + """mytk.browse() collecting every advertised server without connecting.""" + + SERVICE_TYPE = "_mytk._tcp.local." + + def _install_zeroconf(self, services): + fake = _make_fake_zeroconf(services) + patcher = mock.patch.dict(sys.modules, {"zeroconf": fake}) + patcher.start() + self.addCleanup(patcher.stop) + + def test_browse_returns_all_advertised_servers_sorted(self): + self._install_zeroconf([ + (self.SERVICE_TYPE, "B." + self.SERVICE_TYPE, + "192.168.1.20", 2222, {"app": "B"}), + (self.SERVICE_TYPE, "A." + self.SERVICE_TYPE, + "192.168.1.10", 1111, {"app": "A"}), + ]) + + servers = remote.browse(timeout=0.1) + + # Both servers are reported, sorted by advertised name; none is connected. + self.assertEqual( + [(s["app"], s["host"], s["port"]) for s in servers], + [("A", "192.168.1.10", 1111), ("B", "192.168.1.20", 2222)], + ) + + def test_browse_empty_when_no_service(self): + self._install_zeroconf([]) + + self.assertEqual(remote.browse(timeout=0.1), []) + + def test_browse_without_zeroconf_raises_importerror(self): + patcher = mock.patch.dict(sys.modules, {"zeroconf": None}) + patcher.start() + self.addCleanup(patcher.stop) + + with self.assertRaises(ImportError): + remote.browse(timeout=0.1) + + class TestRemoteCLIDiscovery(unittest.TestCase): """`mytk-remote --discover` wiring, with discover() itself stubbed out.""" @@ -213,6 +254,32 @@ def test_discover_missing_zeroconf_reports_error(self): self.assertEqual(code, 2) self.assertIn("zeroconf", err) + def test_browse_lists_all_apps(self): + servers = [ + {"app": "Microscope", "host": "192.168.1.42", + "port": 55444, "service": "Microscope._mytk._tcp.local."}, + {"app": "Laser", "host": "192.168.1.63", + "port": 44011, "service": "Laser._mytk._tcp.local."}, + ] + with mock.patch( + "mytk.remotecli.browse", return_value=servers + ) as browse: + code, out, _ = self._run(["--browse"]) + self.assertEqual(code, 0) + self.assertIn("Microscope\t192.168.1.42:55444", out) + self.assertIn("Laser\t192.168.1.63:44011", out) + browse.assert_called_once_with( + service_type="_mytk._tcp.local.", timeout=3.0 + ) + + def test_browse_needs_no_command(self): + # --browse must not require a positional command, like --list. + with mock.patch("mytk.remotecli.browse", return_value=[]): + code, out, err = self._run(["--browse"]) + self.assertEqual(code, 0) + self.assertEqual(out, "") + self.assertEqual(err, "") + if __name__ == "__main__": unittest.main()