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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,30 @@

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.
- **`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
`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
Expand Down
4 changes: 3 additions & 1 deletion mytk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, browse, connect, discover, remote_app
from .remotecontrollable import RemoteControllable, remote_command
from .tableview import TableView
from .tabulardata import PostponeChangeCalls, TabularData
Expand Down Expand Up @@ -104,7 +104,9 @@
"View3DPyrender",
"Window",
"XYPlot",
"browse",
"connect",
"discover",
"remote_app",
"remote_command",
"tkFont",
Expand Down
169 changes: 169 additions & 0 deletions mytk/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,175 @@ 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"
)


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.

Expand Down
75 changes: 67 additions & 8 deletions mytk/remotecli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@
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
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
``"turn_on()"``. Keyword arguments are not supported — XML-RPC carries
Expand All @@ -20,7 +28,15 @@
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,
browse,
connect,
discover,
)


def parse_command(command):
Expand Down Expand Up @@ -78,7 +94,27 @@ 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(
"--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 "
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",
Expand All @@ -101,11 +137,26 @@ 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:
proxy = connect(args.host, args.port, app_name=args.app_name)
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,
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()
Expand All @@ -124,14 +175,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


Expand Down
Loading
Loading