Skip to content
Closed
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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,25 @@ All notable changes to myTk are documented here.
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).
- **The remote command-line client is now the `mytk` command** (the
`mytk-remote` console script is kept as a compatibility alias; both are the
same tool). Use it to probe, `--list`, `--browse`, `--discover` and drive any
RemoteControllable app.
- **`mytk.remote_cli(argv=None, *, host, port, app_name, prog)`** — a ready-made,
embeddable command-line client. A downstream app's entry point can
`sys.exit(mytk.remote_cli(app_name="My App", prog="my-cli"))` to get a
*branded* CLI with almost no code: it delegates to the same engine as the
`mytk` command, so there is one syntax, one set of exit codes, and one
implementation. `dict` return values now print as an aligned key/value table.
- **`mytk.install_command_on_path(name, *, arguments=(), directories=None)`** —
install a launcher for the current program onto the user's PATH (first
writable of `/usr/local/bin`, `~/.local/bin`, `~/bin`), so an app can
distribute its branded CLI (e.g. `install_command_on_path("my-cli",
arguments=("ctl",))`). The wrapper uses `sys.executable` **verbatim** so the
command keeps this app's virtualenv/dependencies; POSIX-only for now (raises
`NotImplementedError` on Windows).
- **`App.add_file_menu_command(label, command, *, before="Quit", separator=True)`**
inserts an item into the File menu without the tkinter menu-bar plumbing.

## [1.7.1]
### Changed
Expand Down
12 changes: 11 additions & 1 deletion mytk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,16 @@
from .modulesmanager import ModulesManager
from .popupmenu import PopupMenu
from .progressbar import ProgressBar, ProgressBarNotification, ProgressWindow
from .clitools import install_command_on_path
from .radiobutton import RadioButton
from .remote import RemoteAppMismatch, browse, connect, discover, remote_app
from .remote import (
RemoteAppMismatch,
browse,
connect,
discover,
remote_app,
remote_cli,
)
from .remotecontrollable import RemoteControllable, remote_command
from .tableview import TableView
from .tabulardata import PostponeChangeCalls, TabularData
Expand Down Expand Up @@ -107,7 +115,9 @@
"browse",
"connect",
"discover",
"install_command_on_path",
"remote_app",
"remote_cli",
"remote_command",
"tkFont",
"ttk",
Expand Down
30 changes: 30 additions & 0 deletions mytk/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,36 @@ def on_mac_quit():
with suppress(TclError):
self.root.createcommand("tk::mac::Quit", on_mac_quit)

def add_file_menu_command(self, label, command, *, before="Quit",
separator=True):
"""Insert a command into the File menu, above `before` (default Quit).

Saves subclasses the tkinter plumbing of reaching into the menu bar that
:meth:`create_menu` built. Call after the menu exists (e.g. after
``super().__init__()``). If `before` is not found, the item is appended.

Args:
label (str): The menu item's label.
command (callable): Called when the item is chosen.
before (str): Label of the item to insert above. If absent, append.
separator (bool): Add a separator alongside the item.
"""
menubar = self.root.nametowidget(self.root["menu"])
file_menu = self.root.nametowidget(menubar.entrycget("File", "menu"))
try:
index = file_menu.index(before)
except TclError:
index = None
if index is None:
if separator:
file_menu.add_separator()
file_menu.add_command(label=label, command=command)
else:
if separator:
file_menu.insert_separator(index)
index += 1
file_menu.insert_command(index, label=label, command=command)

def reveal_path(self, path):
"""Reveals the file or directory path in the system's file browser.

Expand Down
94 changes: 94 additions & 0 deletions mytk/clitools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""clitools.py — install a launcher for this program onto the user's PATH.

Lets a packaged myTk app (or a source checkout) expose a command-line entry
point — e.g. a control CLI for a :class:`~mytk.remotecontrollable.RemoteControllable`
app — without the user hand-writing shell scripts. Pairs with
:func:`mytk.remote_cli`: the installed launcher re-enters this same program in
CLI mode, which then talks to the running app over the remote API.
"""

import os
import sys
from pathlib import Path

__all__ = ["install_command_on_path"]

DEFAULT_BIN_DIRS = ("/usr/local/bin", "~/.local/bin", "~/bin")


def install_command_on_path(name, *, arguments=(), directories=None):
"""Install a ``name`` command that re-launches this program, on PATH.

Writes a small POSIX wrapper (NOT a bare symlink) that runs the current
program with the SAME interpreter/binary it was installed from, so the
command keeps this environment's dependencies:

* packaged app (``sys.frozen``): the wrapper calls the bundle binary;
* source checkout: it calls this interpreter + the ``__main__`` script.

``arguments`` are inserted before the user's args, so an entry point that
dispatches on a subcommand can be routed into CLI mode — e.g.
``arguments=("ctl",)`` makes the wrapper run ``<program> ctl "$@"``.

Tries each of ``directories`` (default: ``/usr/local/bin``, ``~/.local/bin``,
``~/bin``) and installs into the first writable one, so it works with or
without admin rights.

Args:
name (str): The command name to create (the launcher's filename).
arguments (tuple[str, ...]): Tokens inserted before the user's args, to
route the launcher into a subcommand/CLI mode.
directories (list, optional): Candidate bin directories, tried in order.
Defaults to :data:`DEFAULT_BIN_DIRS`.

Returns:
tuple[pathlib.Path, str]: the created command path, and a note (empty
unless its directory is not on PATH, then it explains how to add it).

Raises:
RuntimeError: if none of the directories are writable.
NotImplementedError: on Windows (POSIX wrapper does not apply).
"""
if sys.platform.startswith("win"):
raise NotImplementedError(
"install_command_on_path writes a POSIX /bin/sh wrapper; Windows "
"needs a different launcher (not implemented).")

# sys.executable VERBATIM — never resolve() it. A venv's python is a symlink
# to the base interpreter; following it drops the venv's site-packages, so
# the installed command would silently lose this env's dependencies.
if getattr(sys, "frozen", False):
parts = [sys.executable]
else:
main = sys.modules.get("__main__")
script = getattr(main, "__file__", None) or sys.argv[0]
parts = [sys.executable, os.path.abspath(script)]
parts += list(arguments)
command = " ".join('"{0}"'.format(part) for part in parts)
wrapper = '#!/bin/sh\nexec {0} "$@"\n'.format(command)

candidates = DEFAULT_BIN_DIRS if directories is None else directories
candidates = [Path(str(d)).expanduser() for d in candidates]

problems = []
for directory in candidates:
entry = directory / name
try:
directory.mkdir(parents=True, exist_ok=True)
if entry.is_symlink() or entry.exists():
entry.unlink()
entry.write_text(wrapper)
entry.chmod(0o755)
except OSError as err:
problems.append("{0} ({1})".format(directory, err))
continue

note = ""
if str(directory) not in os.environ.get("PATH", "").split(os.pathsep):
note = ("{0} is not on your PATH — add it to your shell profile:\n"
" export PATH=\"{0}:$PATH\"".format(directory))
return entry, note

raise RuntimeError(
"Could not write {0} to any of:\n {1}".format(
name, "\n ".join(problems)))
51 changes: 51 additions & 0 deletions mytk/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,57 @@ def remove_service(self, zc, type_, name):
return servers


def remote_cli(argv=None, *, host=DEFAULT_HOST, port=DEFAULT_PORT,
app_name=None, prog="remote-ctl"):
"""A ready-made command-line client for a RemoteControllable app.

A thin, embeddable wrapper over :func:`mytk.remotecli.run` — the same engine
behind the ``mytk`` command — so a downstream app gets a *branded* CLI from
its own entry point with almost no code::

import sys, mytk
sys.exit(mytk.remote_cli(app_name="My App", prog="my-cli"))

Then, with the app running (and its remote server started)::

my-cli "turn_on()" # call an exposed function
my-cli --list # show what the server exposes
my-cli "add(2, 3)" # arguments are Python literals
my-cli --host H --port P … # override the baked-in target

``host``/``port``/``app_name`` become the defaults the user can still
override on the command line. Command syntax, the discovery flags
(``--discover``/``--browse``) and exit codes are exactly those of the
``mytk`` command, so there is one CLI to learn and one implementation to
maintain. Pair it with :func:`mytk.install_command_on_path` to put the
branded command on the user's PATH.

Args:
argv (list[str], optional): Arguments to parse (defaults to
``sys.argv[1:]``).
host (str): Default server host, overridable with ``--host``.
port (int): Default server port, overridable with ``--port``.
app_name (str, optional): Default identity to verify/select,
overridable with ``--app-name``.
prog (str): Program name shown in usage/errors (the branded command).

Returns:
int: exit code — 0 ok, 1 remote error, 2 connection/usage error.
"""
from .remotecli import run

# run() parses with argparse, which raises SystemExit on a usage error
# (e.g. no command). As an embeddable function we return that code instead
# of letting SystemExit escape, so callers get the documented int contract.
try:
return run(
argv, prog=prog, default_host=host, default_port=port,
default_app_name=app_name,
)
except SystemExit as exc:
return exc.code if isinstance(exc.code, int) else 2


class RemoteAppProxy:
"""Module-level proxy that connects on first use.

Expand Down
77 changes: 55 additions & 22 deletions mytk/remotecli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,20 @@
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()"
mytk "add(2, 3)" # the `mytk` console script
mytk --app-name Acquisition "status()"
mytk --list # show the exposed functions
python -m mytk --remote "turn_on()" # equivalent module form
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

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`` is a kept-for-compatibility alias of ``mytk``.) 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
mytk --discover "turn_on()"
mytk --discover --app-name Microscope "status()"
mytk --discover --list
mytk --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
Expand Down Expand Up @@ -74,8 +75,15 @@ def parse_command(command):
return node.func.id, args


def build_parser(prog=None):
"""Build the argument parser for the remote command-line client."""
def build_parser(prog=None, *, default_host=DEFAULT_HOST,
default_port=DEFAULT_PORT, default_app_name=None):
"""Build the argument parser for the remote command-line client.

``default_host``/``default_port``/``default_app_name`` let an embedding app
(see :func:`mytk.remote_cli`) bake in its own connection defaults while
still allowing the user to override them with ``--host``/``--port``/
``--app-name``.
"""
parser = argparse.ArgumentParser(
prog=prog,
description="Call a function on a running RemoteControllable mytk app.",
Expand All @@ -86,14 +94,15 @@ def build_parser(prog=None):
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})"
"--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})",
"--port", type=int, default=default_port,
help=f"server port (default: {default_port})",
)
parser.add_argument(
"--app-name", default=None,
"--app-name", default=default_app_name,
help="verify the server identifies as this name before calling; "
"with --discover, also selects which advertised app to use",
)
Expand Down Expand Up @@ -123,18 +132,43 @@ def build_parser(prog=None):
return parser


def run(argv=None, prog=None):
def _print_result(result):
"""Print a remote call's return value.

A ``dict`` is shown as an aligned ``key value`` table (nicer for status
snapshots); anything else is printed as-is. ``None`` prints nothing.
"""
if result is None:
return
if isinstance(result, dict):
width = max((len(str(key)) for key in result), default=0)
for key, value in result.items():
print(f"{str(key):<{width}} {value}")
else:
print(result)


def run(argv=None, prog=None, *, default_host=DEFAULT_HOST,
default_port=DEFAULT_PORT, default_app_name=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.
default_host (str): Host used when ``--host`` is not given. An embedding
app (via :func:`mytk.remote_cli`) can bake in its own default.
default_port (int): Port used when ``--port`` is not given.
default_app_name (str, optional): Identity to verify/select when
``--app-name`` is not given.

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

if not args.list and not args.browse and not args.command:
Expand Down Expand Up @@ -166,8 +200,7 @@ def run(argv=None, prog=None):

name, call_args = parse_command(args.command)
result = getattr(proxy, name)(*call_args)
if result is not None:
print(result)
_print_result(result)
return 0
except ValueError as exc: # bad command string
print(f"error: {exc}", file=sys.stderr)
Expand Down Expand Up @@ -195,8 +228,8 @@ def run(argv=None, prog=None):


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


if __name__ == "__main__":
Expand Down
Loading