diff --git a/CHANGELOG.md b/CHANGELOG.md index f652014..a30cc03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/mytk/__init__.py b/mytk/__init__.py index 4e8dcef..c244fc5 100644 --- a/mytk/__init__.py +++ b/mytk/__init__.py @@ -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 @@ -107,7 +115,9 @@ "browse", "connect", "discover", + "install_command_on_path", "remote_app", + "remote_cli", "remote_command", "tkFont", "ttk", diff --git a/mytk/app.py b/mytk/app.py index 4e5ce79..dd35bab 100644 --- a/mytk/app.py +++ b/mytk/app.py @@ -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. diff --git a/mytk/clitools.py b/mytk/clitools.py new file mode 100644 index 0000000..6a8d905 --- /dev/null +++ b/mytk/clitools.py @@ -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 `` 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))) diff --git a/mytk/remote.py b/mytk/remote.py index 34e50c1..1ec5b74 100644 --- a/mytk/remote.py +++ b/mytk/remote.py @@ -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. diff --git a/mytk/remotecli.py b/mytk/remotecli.py index b27f725..e796557 100644 --- a/mytk/remotecli.py +++ b/mytk/remotecli.py @@ -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 @@ -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.", @@ -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", ) @@ -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: @@ -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) @@ -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__": diff --git a/mytk/remotecontrollable.py b/mytk/remotecontrollable.py index ce61e5c..20a24d5 100644 --- a/mytk/remotecontrollable.py +++ b/mytk/remotecontrollable.py @@ -21,10 +21,24 @@ def read_status(self): app.mainloop() Clients connect with ``mytk.connect(...)`` or ``mytk.remote_app`` (see -: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). +:mod:`mytk.remote`), or from the command line with the ``mytk`` command +(``mytk-remote`` is a kept-for-compatibility alias) / ``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). + +To give your own app a *branded* control CLI, expose the functions, then have +your entry point delegate to :func:`mytk.remote_cli`, and install it on the +user's PATH with :func:`mytk.install_command_on_path`:: + + # your app's __main__: `myapp ctl …` runs the control CLI + import sys, mytk + if sys.argv[1:2] == ["ctl"]: + sys.exit(mytk.remote_cli(sys.argv[2:], app_name="Server", prog="myapp")) + + # one-time install (uses THIS app's interpreter/venv): + mytk.install_command_on_path("myapp", arguments=("ctl",)) + # then, with the app running: myapp "turn_on()" / myapp --list For a free function, or to register something dynamically at runtime, the low-level primitive ``app.remote(fct, name=...)`` registers it directly; diff --git a/mytk/tests/testCliTools.py b/mytk/tests/testCliTools.py new file mode 100644 index 0000000..c62a444 --- /dev/null +++ b/mytk/tests/testCliTools.py @@ -0,0 +1,248 @@ +"""Tests for the CLI-tool helpers: install_command_on_path, remote_cli, and +App.add_file_menu_command. + +The install tests are POSIX-only (the helper writes a /bin/sh wrapper and raises +NotImplementedError on Windows). The remote_cli tests spin up a real server and +drive it over a socket, like testRemote.py / testRemoteCLI.py. +""" + +import contextlib +import io +import os +import stat +import sys +import tempfile +import threading +import unittest +from pathlib import Path +from unittest import mock + +from mytk import App, RemoteControllable +from mytk.clitools import install_command_on_path +from mytk.remote import remote_cli +from mytk.remotecli import _print_result + + +@unittest.skipIf(sys.platform.startswith("win"), + "install_command_on_path is POSIX-only") +class TestInstallCommandOnPath(unittest.TestCase): + """Writing an executable launcher into a writable bin dir.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + + def test_writes_executable_wrapper_with_verbatim_executable(self): + good = self.root / "bin" + path, note = install_command_on_path( + "my-cli", arguments=("ctl",), directories=[good]) + + self.assertTrue(path.exists()) + mode = stat.S_IMODE(path.stat().st_mode) + self.assertEqual(mode, 0o755) + content = path.read_text() + self.assertTrue(content.startswith("#!/bin/sh")) + # The exact interpreter this env runs, NOT a resolved base interpreter. + self.assertIn(sys.executable, content) + # arguments are placed before the forwarded user args ("$@"). + self.assertIn('"ctl" "$@"', content) + + def test_falls_back_to_next_writable_directory(self): + # A path *under a regular file* cannot be created as a directory. + blocker = self.root / "not-a-dir" + blocker.write_text("x") + bad = blocker / "bin" + good = self.root / "good" + + path, _ = install_command_on_path( + "my-cli", directories=[bad, good]) + + self.assertEqual(path.parent, good) + self.assertTrue(path.exists()) + + def test_raises_when_no_directory_is_writable(self): + blocker = self.root / "file" + blocker.write_text("x") + bad = blocker / "bin" + with self.assertRaises(RuntimeError): + install_command_on_path("my-cli", directories=[bad]) + + def test_note_empty_when_dir_on_path_else_explains(self): + good = self.root / "bin" + + with mock.patch.dict(os.environ, {"PATH": str(good)}): + _, note = install_command_on_path("my-cli", directories=[good]) + self.assertEqual(note, "") + + with mock.patch.dict(os.environ, {"PATH": "/usr/bin"}): + _, note = install_command_on_path("my-cli", directories=[good]) + self.assertIn("not on your PATH", note) + + def test_overwrites_existing_file_or_symlink(self): + good = self.root / "bin" + good.mkdir() + stale = good / "my-cli" + stale.write_text("old contents") + + path, _ = install_command_on_path("my-cli", directories=[good]) + self.assertEqual(path, stale) + self.assertTrue(path.read_text().startswith("#!/bin/sh")) + + +class _CliApp(App, RemoteControllable): + def __init__(self, **kwargs): + super().__init__(no_window=True, **kwargs) + + def add(self, a, b): + return a + b + + def snapshot(self): + return {"x": 1, "y": 2} + + +class TestRemoteCli(unittest.TestCase): + """remote_cli() drives a live RemoteControllable app end-to-end, reusing the + general `mytk` CLI engine (paren-call syntax, its exit codes).""" + + def setUp(self): + self.app = _CliApp(name="T") + self.app.remote(self.app.add) + self.app.remote(self.app.snapshot) + self.rc = None + self.output = None + + def tearDown(self): + self.app.quit() + + def _run_cli(self, argv, *, app_name=None, host=None, port=None, + timeout=5000): + # Quit as soon as the client thread finishes (polled on the main + # thread); the queue keeps draining until then. `timeout` is a safety + # net. (Same robust pattern as testRemote._run_with_client.) + kwargs = {} + if app_name is not None: + kwargs["app_name"] = app_name + if host is not None: + kwargs["host"] = host + if port is not None: + kwargs["port"] = port + + def client_call(): + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + self.rc = remote_cli(argv, **kwargs) + self.output = buffer.getvalue() + + thread = threading.Thread(target=client_call) + + def check(remaining): + if not thread.is_alive() or remaining <= 0: + self.app.quit() + else: + self.app.after(25, lambda: check(remaining - 25)) + + def start(): + thread.start() + check(timeout) + + self.app.after(50, start) + self.app.mainloop() + thread.join(timeout=3) + + def test_call_prints_result(self): + port = self.app.start_remote(port=0) + self._run_cli(["add(2, 3)"], app_name="T", port=port) + self.assertEqual(self.rc, 0) + self.assertEqual(self.output.strip(), "5") + + def test_dict_result_printed_as_table(self): + port = self.app.start_remote(port=0) + self._run_cli(["snapshot()"], app_name="T", port=port) + self.assertEqual(self.rc, 0) + self.assertIn("x 1", self.output) + self.assertIn("y 2", self.output) + + def test_list_uses_introspected_api(self): + port = self.app.start_remote(port=0) + self._run_cli(["--list"], port=port) + self.assertEqual(self.rc, 0) + self.assertIn("add(a, b)", self.output) + self.assertIn("snapshot()", self.output) + + def test_unknown_command_exits_1(self): + # Reconciled with the `mytk` tool: an unknown command is not + # pre-validated (that would cost a round-trip per call); the server + # faults and the CLI reports a remote error (exit 1). + port = self.app.start_remote(port=0) + self._run_cli(["nope()"], port=port) + self.assertEqual(self.rc, 1) + + def test_no_command_is_usage_error_2(self): + port = self.app.start_remote(port=0) + self._run_cli([], port=port) + self.assertEqual(self.rc, 2) + + def test_app_name_mismatch_exits_2(self): + port = self.app.start_remote(port=0, app_name="Real") + self._run_cli(["add(2, 3)"], app_name="Wrong", port=port) + self.assertEqual(self.rc, 2) + + def test_connection_refused_exits_1(self): + # No server here; myTk's convention is 1 (runtime) for an unreachable + # app, same as the `mytk` command — no mainloop needed. + self.assertEqual(remote_cli(["add(2, 3)"], host="127.0.0.1", port=1), 1) + + +class TestPrintResult(unittest.TestCase): + """_print_result formatting, in isolation.""" + + def _capture(self, value): + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + _print_result(value) + return buffer.getvalue() + + def test_none_prints_nothing(self): + self.assertEqual(self._capture(None), "") + + def test_scalar_printed_plainly(self): + self.assertEqual(self._capture(5).strip(), "5") + + def test_dict_aligned(self): + out = self._capture({"aa": 1, "b": 2}) + # keys padded to the widest key ("aa" -> width 2). + self.assertIn("aa 1", out) + self.assertIn("b 2", out) + + +class TestAddFileMenuCommand(unittest.TestCase): + """App.add_file_menu_command inserts into the File menu.""" + + def setUp(self): + self.app = App(name=self.id(), no_window=True) + + def tearDown(self): + self.app.quit() + + def _file_menu(self): + menubar = self.app.root.nametowidget(self.app.root["menu"]) + return self.app.root.nametowidget(menubar.entrycget("File", "menu")) + + def test_inserts_above_quit(self): + self.app.add_file_menu_command("Do Thing", lambda: None, separator=False) + file_menu = self._file_menu() + do_index = file_menu.index("Do Thing") + quit_index = file_menu.index("Quit") + self.assertIsNotNone(do_index) + self.assertLess(do_index, quit_index) + + def test_appends_when_before_absent(self): + self.app.add_file_menu_command( + "Tail", lambda: None, before="Nonexistent", separator=False) + file_menu = self._file_menu() + self.assertEqual(file_menu.index("Tail"), file_menu.index("end")) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyproject.toml b/pyproject.toml index 4050949..1d58930 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,8 @@ classifiers = [ ] [project.scripts] -mytk-remote = "mytk.remotecli:main" +mytk = "mytk.remotecli:main" +mytk-remote = "mytk.remotecli:main" # kept-for-compatibility alias of `mytk` [project.urls] Homepage = "https://github.com/DCC-Lab/myTk"