807 lines
34 KiB
Python
807 lines
34 KiB
Python
#!/usr/bin/env python
|
|
"""Console entry point for eco (the ``eco`` command).
|
|
|
|
This module is deliberately kept OUTSIDE the ``eco`` package and imports only
|
|
the standard library. Importing it must NOT import ``eco`` itself: the heavy
|
|
package import (EPICS, cam_server, ...) is expensive and best done once, inside
|
|
the fresh session that this launcher starts -- not in the parent process that
|
|
only needs to build a command line.
|
|
|
|
Four subcommands (``eco console`` is the default -- a bare ``eco`` is exactly
|
|
``eco console``):
|
|
|
|
console Interactive IPython session (the traditional eco startup).
|
|
Equivalent to ipython --profile=eco --no-banner -i
|
|
-c "run -m eco.startup_inline -l -s bernina"
|
|
desktop A Spyder/MATLAB-like Qt workbench window: an embedded IPython
|
|
console (on by default; --no-console to skip it), plus --
|
|
only if -s/--scope is given -- a dockable "Namespace" panel
|
|
to browse and open device widgets. Without -s, this is just
|
|
a plain Qt console, no namespace attached. See
|
|
eco.widgets.desktop_app.
|
|
webapp Serve the packaged eco notebook (eco/voila_app.ipynb) as a
|
|
read-only Voila dashboard: the chosen namespace's widget,
|
|
with the assembly browser to navigate to more components.
|
|
jupyterlab Open JupyterLab on that same notebook. If -s/--scope is
|
|
given, this also registers (and makes the default) a Jupyter
|
|
kernel that preloads eco.<scope> -- see _run_jupyterlab --
|
|
so any *new* console or notebook you open from JupyterLab's
|
|
own launcher starts with the namespace already loaded too,
|
|
not just the one pre-opened notebook.
|
|
|
|
JupyterLab, Voila and the desktop UI's qtconsole are OPTIONAL -- they are not
|
|
hard dependencies. Install them on demand, e.g. ``pip install eco[lab]`` /
|
|
``eco[voila]`` or via conda. If the command is missing, this launcher prints a
|
|
hint instead of a traceback.
|
|
|
|
Defaults can be set in an ``.ecorc`` (INI) file so that a bare ``eco`` just
|
|
works. Lookup order (first match wins):
|
|
|
|
1. $ECORC
|
|
2. ./.ecorc (current directory)
|
|
3. ~/.ecorc (home directory)
|
|
|
|
Example ``.ecorc``::
|
|
|
|
[eco]
|
|
command = console
|
|
scope = bernina
|
|
profile = eco
|
|
lazy = true
|
|
|
|
Any of these can be overridden on the command line, e.g. ``eco -s alvra`` or
|
|
``eco jupyterlab -s alvra``.
|
|
"""
|
|
|
|
import argparse
|
|
import configparser
|
|
import importlib.util
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Built-in defaults, overridden by .ecorc, overridden again by CLI flags.
|
|
_BUILTIN_DEFAULTS = {
|
|
"command": "console",
|
|
"scope": "bernina",
|
|
"profile": "eco",
|
|
"lazy": True,
|
|
}
|
|
|
|
_COMMANDS = ("console", "desktop", "webapp", "jupyterlab", "box-server")
|
|
|
|
|
|
def _ecorc_path():
|
|
"""Return the .ecorc file that applies, or None."""
|
|
candidates = []
|
|
env = os.environ.get("ECORC")
|
|
if env:
|
|
candidates.append(Path(env))
|
|
candidates.append(Path.cwd() / ".ecorc")
|
|
candidates.append(Path.home() / ".ecorc")
|
|
for path in candidates:
|
|
if path.is_file():
|
|
return path
|
|
return None
|
|
|
|
|
|
def _load_defaults():
|
|
"""Merge built-in defaults with the [eco] section of the .ecorc, if any."""
|
|
defaults = dict(_BUILTIN_DEFAULTS)
|
|
path = _ecorc_path()
|
|
if path is not None:
|
|
cfg = configparser.ConfigParser()
|
|
cfg.read(path)
|
|
if cfg.has_section("eco"):
|
|
sec = cfg["eco"]
|
|
if "command" in sec:
|
|
defaults["command"] = sec.get("command")
|
|
if "scope" in sec:
|
|
defaults["scope"] = sec.get("scope") or None
|
|
if "profile" in sec:
|
|
defaults["profile"] = sec.get("profile")
|
|
if "lazy" in sec:
|
|
defaults["lazy"] = sec.getboolean("lazy")
|
|
return defaults, path
|
|
|
|
|
|
def _normalize_argv(argv, default_command):
|
|
"""Insert the default subcommand when none was given, so a bare `eco`
|
|
(or `eco -s alvra`, `eco --no-lazy`, ...) works without spelling out
|
|
`eco console` every time. Left alone if the first token already is a
|
|
known subcommand, or is -h/--help (so `eco --help` shows the top-level
|
|
help/subcommand list, not `console`'s)."""
|
|
argv = list(argv)
|
|
if not argv:
|
|
return [default_command]
|
|
if argv[0] in ("-h", "--help") or argv[0] in _COMMANDS:
|
|
return argv
|
|
return [default_command] + argv
|
|
|
|
|
|
def _package_file(filename):
|
|
"""Return the path of a file shipped inside the eco package WITHOUT
|
|
importing eco. ``find_spec`` resolves the package location via the import
|
|
machinery but does not execute ``eco/__init__.py``.
|
|
"""
|
|
spec = importlib.util.find_spec("eco")
|
|
if spec is None or not spec.submodule_search_locations:
|
|
raise SystemExit("eco: cannot locate the installed 'eco' package")
|
|
return os.path.join(list(spec.submodule_search_locations)[0], filename)
|
|
|
|
|
|
def _exec(cmd, missing_hint):
|
|
"""Replace this process with ``cmd`` (list). On a missing executable print
|
|
``missing_hint`` instead of a traceback.
|
|
"""
|
|
try:
|
|
os.execvp(cmd[0], cmd)
|
|
except FileNotFoundError:
|
|
sys.exit("eco: '{}' not found. {}".format(cmd[0], missing_hint))
|
|
|
|
|
|
def _run_console(args):
|
|
# Module-mode (`-m eco.startup_inline`), not a bare file path: IPython's
|
|
# %run inserts the *run script's own directory* onto sys.path (mirroring
|
|
# `python script.py`), which for a file path resolving inside the eco
|
|
# package means eco's own install directory gets prepended. eco ships a
|
|
# subpackage literally named eco/epics/, so that directory shadows the
|
|
# real third-party `epics` (pyepics) package the moment it's on
|
|
# sys.path -- `import epics.pv` elsewhere in eco then resolves to
|
|
# eco.epics_utils (which has no `pv` submodule) instead of pyepics, raising
|
|
# `ModuleNotFoundError: No module named 'epics.pv'`. Module-mode resolves
|
|
# eco.startup_inline through the normal import system instead, so it
|
|
# never adds that extra directory.
|
|
run_cmd = "run -m eco.startup_inline"
|
|
if args.lazy:
|
|
run_cmd += " -l"
|
|
if args.scope:
|
|
run_cmd += " -s {}".format(args.scope)
|
|
_exec(
|
|
["ipython", "--profile={}".format(args.profile), "--no-banner", "-i", "-c", run_cmd],
|
|
"It ships with eco's core dependencies (IPython).",
|
|
)
|
|
|
|
|
|
def _run_desktop(args):
|
|
"""Launch the Qt desktop workbench (see eco.widgets.desktop_app) as a
|
|
fresh process -- exec, like the other _run_* functions, so this
|
|
launcher module stays import-eco-free (see the module docstring).
|
|
Without -s/--scope, this is a plain embedded Qt console with no
|
|
namespace/launcher panel (see eco.widgets.desktop_app.EcoDesktopApp)."""
|
|
cmd = [sys.executable, "-m", "eco.widgets.desktop_app"]
|
|
if args.scope:
|
|
cmd += ["--scope", args.scope]
|
|
cmd += ["--lazy"] if args.lazy else ["--no-lazy"]
|
|
if not args.console:
|
|
cmd += ["--no-console"]
|
|
if not args.namespace_panel:
|
|
cmd += ["--no-namespace-panel"]
|
|
if args.theme:
|
|
cmd += ["--theme", args.theme]
|
|
if args.workspace:
|
|
cmd += ["--workspace", args.workspace]
|
|
_exec(
|
|
cmd,
|
|
"The desktop UI needs qtconsole and a Qt binding (qtpy + PyQt5/PySide6) "
|
|
"in this environment.",
|
|
)
|
|
|
|
|
|
def _run_webapp(args):
|
|
"""Serve the packaged notebook (eco/voila_app.ipynb) as a read-only
|
|
Voila dashboard, passing the scope/lazy choice through the environment
|
|
(the notebook itself reads these -- see its first cell)."""
|
|
notebook = _package_file("voila_app.ipynb")
|
|
os.environ["ECO_SCOPE"] = args.scope or ""
|
|
os.environ["ECO_LAZY"] = "1" if args.lazy else "0"
|
|
_exec(
|
|
["voila", notebook],
|
|
"Install it with e.g. `pip install eco[voila]` or `conda install voila`.",
|
|
)
|
|
|
|
|
|
def _run_box_server(args):
|
|
"""Hold this console's connection to the physical manual-control box
|
|
as a background service - see eco.manual_control.box_server. Exec's
|
|
into that module (like every other _run_* here), so this launcher
|
|
stays import-eco-free; -s/--scope picks which instrument namespace to
|
|
offer the box (imports eco.<scope>, same as `eco console -s <scope>`).
|
|
|
|
This is the "run it from a checkout, for quick dev/testing" path -
|
|
same module the production `eco-box-server` wrapper script runs, just
|
|
without that script's start/stop/systemd/log-file machinery. Runs in
|
|
the foreground; Ctrl-C disconnects from the box and exits.
|
|
"""
|
|
if not args.scope:
|
|
sys.exit(
|
|
"eco: box-server needs -s/--scope to know which instrument "
|
|
"namespace to offer the box, e.g. `eco box-server -s bernina`"
|
|
)
|
|
cmd = [sys.executable, "-m", "eco.manual_control.box_server", "-s", args.scope]
|
|
if args.box_host:
|
|
cmd += ["--box-host", args.box_host]
|
|
if args.box_port:
|
|
cmd += ["--box-port", str(args.box_port)]
|
|
if args.token_file:
|
|
cmd += ["--token-file", args.token_file]
|
|
cmd += ["--host", args.host, "--port", str(args.port)]
|
|
_exec(
|
|
cmd,
|
|
"It ships with eco itself (eco.manual_control.box_server); "
|
|
"the admin/health API additionally needs flask (`pip install eco[gui]`).",
|
|
)
|
|
|
|
|
|
def _ipython_profile_dir(profile_name):
|
|
"""Where IPython keeps a profile's config/startup files -- same
|
|
resolution IPython itself uses (~/.ipython, or $IPYTHONDIR if set)."""
|
|
base = os.environ.get("IPYTHONDIR") or str(Path.home() / ".ipython")
|
|
return Path(base) / "profile_{}".format(profile_name)
|
|
|
|
|
|
def _jupyter_kernel_dir(kernel_name):
|
|
"""Where a user-level Jupyter kernelspec lives -- same resolution
|
|
`jupyter kernelspec install --user` uses (~/.local/share/jupyter/kernels
|
|
on Linux, which is what this sandbox and every real beamline account
|
|
both are)."""
|
|
base = os.environ.get("JUPYTER_DATA_DIR") or str(
|
|
Path.home() / ".local" / "share" / "jupyter"
|
|
)
|
|
return Path(base) / "kernels" / kernel_name
|
|
|
|
|
|
def _register_eco_kernel(scope, lazy):
|
|
"""Write (idempotently -- safe to call every launch) an IPython profile
|
|
startup file that preloads eco.<scope> exactly like `eco console` does
|
|
(import eco.<scope> as <scope>; from eco.<scope> import *), plus a
|
|
Jupyter kernelspec that uses that profile. Returns the kernel's name.
|
|
|
|
WHY a whole profile+kernelspec, not just opening one pre-built
|
|
notebook: IPython/ipykernel run a profile's startup/*.py on every
|
|
kernel launch under `--profile=<name>` -- not just this one process,
|
|
every future one too. So once this kernel is registered, ANY new
|
|
console or notebook opened from JupyterLab's own launcher (not just a
|
|
notebook eco_cli.py happens to point jupyter at) starts with the
|
|
namespace already loaded, matching eco desktop's console -- see
|
|
eco.widgets.desktop_app's build_namespace_vars docstring for why a
|
|
plain `namespace` variable alone wouldn't be enough.
|
|
"""
|
|
kernel_name = "eco-{}".format(scope)
|
|
|
|
profile_dir = _ipython_profile_dir(kernel_name)
|
|
startup_dir = profile_dir / "startup"
|
|
startup_dir.mkdir(parents=True, exist_ok=True)
|
|
lazy_line = "ecocnf.startup_lazy = True\n" if lazy else ""
|
|
(startup_dir / "00-eco-scope.py").write_text(
|
|
"# Auto-generated by eco jupyterlab -- safe to delete, regenerated\n"
|
|
"# every launch from the -s/-l flags given then.\n"
|
|
"from eco import ecocnf\n"
|
|
+ lazy_line
|
|
+ "import eco.{scope} as {scope}\n"
|
|
"from eco.{scope} import *\n"
|
|
# Same reasoning as eco console/eco desktop's console: Jedi can
|
|
# fully resolve a still-lazy device proxy just from it being a
|
|
# completion candidate, and was independently measured slower and
|
|
# less correct here regardless (see console_kernel.
|
|
# build_console_widget's docstring).
|
|
"get_ipython().Completer.use_jedi = False\n"
|
|
# Every console/notebook run against this kernel (not just this
|
|
# one, and not just this launch -- profile startup files run on
|
|
# every future kernel launch under --profile=eco-{scope} too) gets
|
|
# the same activity log eco desktop's console already has -- see
|
|
# kernel_registry.install_shell_logger's docstring for why this
|
|
# needs a different mechanism than that (ZMQ-message-based)
|
|
# approach.
|
|
"from eco.widgets import kernel_registry\n"
|
|
"kernel_registry.install_shell_logger(kind='jupyterlab', label={scope!r})\n".format(
|
|
scope=scope
|
|
)
|
|
)
|
|
|
|
kernel_dir = _jupyter_kernel_dir(kernel_name)
|
|
kernel_dir.mkdir(parents=True, exist_ok=True)
|
|
import json
|
|
|
|
(kernel_dir / "kernel.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"argv": [
|
|
sys.executable,
|
|
"-m",
|
|
"ipykernel_launcher",
|
|
"-f",
|
|
"{connection_file}",
|
|
"--profile={}".format(kernel_name),
|
|
],
|
|
"display_name": "eco ({})".format(scope),
|
|
"language": "python",
|
|
}
|
|
)
|
|
)
|
|
return kernel_name
|
|
|
|
|
|
def _write_jupyterlab_notebook(kernel_name, scope):
|
|
"""Copy the packaged eco/jupyterlab_app.ipynb to a writable, scope-
|
|
specific path with its kernelspec pointed at `kernel_name` -- regenerated
|
|
(idempotent) every launch, like _register_eco_kernel's own files.
|
|
|
|
WHY a copy, not just opening the packaged file directly: opening an
|
|
*existing* notebook makes JupyterLab use the kernel named in that
|
|
file's own kernelspec metadata, not whatever --MappingKernelManager.
|
|
default_kernel_name says (that only applies to a brand-new console/
|
|
notebook created from the Launcher) -- so without this, the notebook
|
|
would run on a plain, un-preloaded "python3" kernel regardless of the
|
|
kernel _register_eco_kernel just registered.
|
|
"""
|
|
import json
|
|
|
|
src = _package_file("jupyterlab_app.ipynb")
|
|
data = json.loads(Path(src).read_text())
|
|
data["metadata"]["kernelspec"] = {
|
|
"display_name": "eco ({})".format(scope),
|
|
"language": "python",
|
|
"name": kernel_name,
|
|
}
|
|
dest_dir = Path.home() / ".eco"
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
dest = dest_dir / "jupyterlab_app_{}.ipynb".format(scope)
|
|
dest.write_text(json.dumps(data, indent=1))
|
|
return str(dest)
|
|
|
|
|
|
def _notebook_setup_code(notebook_path):
|
|
"""Source of `notebook_path`'s one code cell (see eco/jupyterlab_app.ipynb
|
|
-- a markdown cell then exactly one code cell that builds the Namespace/
|
|
Sidecar panels) -- what `_autorun_jupyterlab_setup` below tries to run
|
|
for the user automatically."""
|
|
import json
|
|
|
|
data = json.loads(Path(notebook_path).read_text())
|
|
for cell in data.get("cells", []):
|
|
if cell.get("cell_type") == "code":
|
|
return "".join(cell.get("source", []))
|
|
return None
|
|
|
|
|
|
def _autorun_jupyterlab_setup(kernel_name, notebook_path):
|
|
"""Best-effort: run notebook_path's setup cell for the user, so the
|
|
Namespace/Sidecar panels usually appear with no manual step, instead of
|
|
sitting inert until someone opens the notebook and presses Shift-Enter.
|
|
|
|
MUST be run in its own OS process (see call site -- multiprocessing.
|
|
Process, not a thread): _run_jupyterlab always ends by exec()ing over
|
|
itself (either straight into `jupyter lab`, or into a companion
|
|
`jupyter console`), which would kill an in-process thread but leaves an
|
|
already-forked child process running independently.
|
|
|
|
Why this can only ever be best-effort, not a real fix: Jupyter kernels
|
|
broadcast comm_open/widget-state messages over IOPub to every client
|
|
already subscribed when they're emitted -- there's no replay for a
|
|
client (here, the browser tab JupyterLab is about to open) that
|
|
subscribes later. So this races the browser's own page load: create the
|
|
session (which is also what makes JupyterLab's own frontend attach to
|
|
THIS kernel instead of starting a second one for the same notebook path),
|
|
wait a bit for the tab to load and its kernel websocket to subscribe,
|
|
then execute the setup cell ourselves over a second client attached to
|
|
that same kernel. If we win the race, the panels appear untouched by the
|
|
user; if we lose it (slow browser start, slow network, ...), nothing
|
|
renders and the fallback is exactly today's behaviour -- open the
|
|
notebook, Shift-Enter the one code cell. Never lets a failure here
|
|
propagate -- this must not affect the normal launch path.
|
|
"""
|
|
import subprocess
|
|
import time
|
|
import json
|
|
import urllib.request
|
|
import urllib.parse
|
|
|
|
try:
|
|
code = _notebook_setup_code(notebook_path)
|
|
if not code:
|
|
return
|
|
|
|
# 1. wait for the server to come up, and get its base URL (token
|
|
# included) -- same "jupyter lab list" this module's sibling
|
|
# (eco.widgets.app_launchers._find_running_jupyterlab) already
|
|
# parses, duplicated here rather than imported since this module
|
|
# deliberately imports nothing from the `eco` package itself.
|
|
base_url = None
|
|
deadline = time.monotonic() + 20
|
|
while time.monotonic() < deadline and base_url is None:
|
|
try:
|
|
out = subprocess.check_output(
|
|
["jupyter", "lab", "list"], stderr=subprocess.DEVNULL,
|
|
text=True, timeout=5,
|
|
)
|
|
for line in out.splitlines():
|
|
line = line.strip()
|
|
if line.startswith("http"):
|
|
base_url = line.split("::")[0].strip()
|
|
break
|
|
except Exception:
|
|
pass
|
|
if base_url is None:
|
|
time.sleep(0.5)
|
|
if base_url is None:
|
|
return
|
|
|
|
# 2. create a session for this exact notebook on our known kernel --
|
|
# this is also what makes JupyterLab's own frontend, when it opens
|
|
# that same notebook path, find and attach to THIS kernel rather
|
|
# than starting a second one.
|
|
parsed = urllib.parse.urlsplit(base_url)
|
|
token = urllib.parse.parse_qs(parsed.query).get("token", [None])[0]
|
|
api_root = urllib.parse.urlunsplit(
|
|
(parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "")
|
|
)
|
|
req = urllib.request.Request(
|
|
api_root + "/api/sessions",
|
|
data=json.dumps({
|
|
"path": os.path.basename(str(notebook_path)),
|
|
"type": "notebook",
|
|
"kernel": {"name": kernel_name},
|
|
}).encode(),
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
**({"Authorization": "token " + token} if token else {}),
|
|
},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
session = json.loads(resp.read())
|
|
kernel_id = session["kernel"]["id"]
|
|
|
|
# 3. locate that kernel's local connection file -- same machine, so
|
|
# readable directly without going through the REST/websocket layer.
|
|
runtime_dir = subprocess.check_output(
|
|
["jupyter", "--runtime-dir"], text=True, timeout=5
|
|
).strip()
|
|
conn_file = Path(runtime_dir) / "kernel-{}.json".format(kernel_id)
|
|
deadline = time.monotonic() + 10
|
|
while not conn_file.exists() and time.monotonic() < deadline:
|
|
time.sleep(0.3)
|
|
if not conn_file.exists():
|
|
return
|
|
|
|
# 4. give the browser tab (already opened by `jupyter lab` itself)
|
|
# a moment to load and its kernel websocket to subscribe -- see the
|
|
# docstring above for why this specific wait is the whole ballgame.
|
|
time.sleep(4)
|
|
|
|
# 5. run the setup cell over a second client attached to the same
|
|
# kernel -- fire-and-forget, we don't wait for/need the reply.
|
|
from jupyter_client import BlockingKernelClient
|
|
|
|
client = BlockingKernelClient()
|
|
client.load_connection_file(str(conn_file))
|
|
client.start_channels()
|
|
try:
|
|
client.execute(code)
|
|
time.sleep(2) # let the execute_request actually go out over ZMQ
|
|
finally:
|
|
client.stop_channels()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _run_jupyterlab(args):
|
|
"""Open JupyterLab on the packaged eco/jupyterlab_app.ipynb (background
|
|
process -- see below for why this can't be the usual _exec) -- its
|
|
Namespace panel and each opened device are real Sidecar dock panels,
|
|
not webapp's single inline page (see eco.widgets.jupyter_sidecar; that
|
|
notebook's own top cell has the details). If -s/--scope is given, also
|
|
registers an eco-<scope> kernel (see _register_eco_kernel), regenerates
|
|
a copy of that notebook pointed at it (see _write_jupyterlab_notebook
|
|
-- opening an *existing* notebook always uses the kernel named in its
|
|
own file, not JupyterLab's server-wide default), and makes that kernel
|
|
JupyterLab's default too, so a fresh Console or Notebook opened from
|
|
its own launcher also comes preloaded. Then, unless --no-console, also
|
|
opens a real `jupyter console` on that same kernel in *this* terminal
|
|
-- mirroring `eco desktop`'s "console on by default, --no-console to
|
|
skip it" -- so you get an actual interactive prompt with eco.<scope>
|
|
preloaded (bare names, exactly like `eco console`), not just a kernel
|
|
sitting there available for JupyterLab's own launcher to pick.
|
|
"""
|
|
import subprocess
|
|
|
|
os.environ["ECO_SCOPE"] = args.scope or ""
|
|
os.environ["ECO_LAZY"] = "1" if args.lazy else "0"
|
|
kernel_name = None
|
|
if args.scope:
|
|
kernel_name = _register_eco_kernel(args.scope, args.lazy)
|
|
notebook = _write_jupyterlab_notebook(kernel_name, args.scope)
|
|
print(
|
|
"eco: registered Jupyter kernel '{}' (preloads eco.{}) as the "
|
|
"default for new consoles/notebooks in this session.".format(
|
|
kernel_name, args.scope
|
|
),
|
|
file=sys.stderr,
|
|
)
|
|
else:
|
|
notebook = _package_file("jupyterlab_app.ipynb")
|
|
lab_cmd = ["jupyter", "lab", notebook]
|
|
if kernel_name:
|
|
lab_cmd.append("--MappingKernelManager.default_kernel_name={}".format(kernel_name))
|
|
|
|
if kernel_name:
|
|
# Best-effort auto-run of the notebook's setup cell -- see
|
|
# _autorun_jupyterlab_setup's docstring for what this does and why
|
|
# it's not guaranteed. Spawned as a real child process (not a
|
|
# thread) *before* the exec() calls below replace this process,
|
|
# since fork survives that, a thread wouldn't.
|
|
import multiprocessing
|
|
|
|
multiprocessing.Process(
|
|
target=_autorun_jupyterlab_setup, args=(kernel_name, notebook), daemon=False,
|
|
).start()
|
|
|
|
if not args.console:
|
|
_exec(
|
|
lab_cmd,
|
|
"Install it with e.g. `pip install eco[lab]` or `conda install jupyterlab`.",
|
|
)
|
|
return # _exec only returns on a missing executable (already sys.exit'd)
|
|
|
|
if not args.scope:
|
|
print(
|
|
"eco: --console needs -s/--scope to know what to preload -- "
|
|
"no scope given, so only opening the plain JupyterLab tab "
|
|
"(use --no-console to silence this).",
|
|
file=sys.stderr,
|
|
)
|
|
_exec(
|
|
lab_cmd,
|
|
"Install it with e.g. `pip install eco[lab]` or `conda install jupyterlab`.",
|
|
)
|
|
return
|
|
|
|
# jupyter lab runs as a genuine background server (its own long-lived
|
|
# process, not something this launcher waits on); the console below
|
|
# becomes THIS process's interactive surface, same as `eco console` --
|
|
# so it has to be a real subprocess, not the usual os.execvp _exec
|
|
# (which would replace this process and never reach the console at
|
|
# all).
|
|
try:
|
|
subprocess.Popen(lab_cmd)
|
|
except FileNotFoundError:
|
|
sys.exit(
|
|
"eco: 'jupyter' not found. Install it with e.g. "
|
|
"`pip install eco[lab]` or `conda install jupyterlab`."
|
|
)
|
|
print(
|
|
"eco: also opening a Jupyter console on kernel '{}' (eco.{} "
|
|
"preloaded) -- Ctrl-D exits just the console, JupyterLab keeps "
|
|
"running.".format(kernel_name, args.scope),
|
|
file=sys.stderr,
|
|
)
|
|
_exec(
|
|
["jupyter", "console", "--kernel", kernel_name],
|
|
"Install it with e.g. `pip install eco[lab]` or `conda install jupyterlab`.",
|
|
)
|
|
|
|
|
|
_EPILOG = """\
|
|
Subcommands:
|
|
console Interactive IPython session (default). The traditional eco
|
|
startup: an IPython shell with the chosen scope's devices
|
|
loaded into its namespace, ready to use interactively.
|
|
desktop A Spyder/MATLAB-like Qt workbench window: an embedded
|
|
IPython console (on by default; --no-console to skip it)
|
|
plus, only if -s/--scope is given, a dockable "Namespace"
|
|
panel to browse and open device widgets. Without -s, it's
|
|
just a plain Qt console -- no namespace attached. Needs
|
|
qtconsole and a Qt binding (qtpy + PyQt5/PySide6) in this
|
|
environment; see eco.widgets.desktop_app.
|
|
webapp Serve the packaged notebook as a read-only Voila dashboard:
|
|
widgets for the chosen namespace, with an assembly browser
|
|
to navigate to more components. No code editing, just the UI.
|
|
jupyterlab Open JupyterLab on a small notebook whose Namespace panel
|
|
and each opened device are real Sidecar dock panels (see
|
|
eco.widgets.jupyter_sidecar) -- the closest browser-side
|
|
analogue to eco desktop's dockable Namespace panel, not
|
|
webapp's single inline page. With -s/--scope, also registers
|
|
an eco-<scope> Jupyter kernel and makes it the default, so a
|
|
fresh Console/Notebook you open from JupyterLab's own
|
|
launcher starts with eco.<scope> already loaded too. Also
|
|
opens a real `jupyter console` on that kernel right in this
|
|
terminal (on by default, matching desktop; --no-console to
|
|
skip it) -- an actual interactive prompt with eco.<scope>
|
|
preloaded, not just a kernel sitting there for later.
|
|
|
|
Configuring defaults with .ecorc:
|
|
A bare `eco` reads its defaults (command/scope/profile/lazy) from an
|
|
.ecorc (INI) file, so you don't have to repeat flags every time. Lookup
|
|
order, first match wins: $ECORC, ./.ecorc, ~/.ecorc. Example file:
|
|
|
|
[eco]
|
|
command = console
|
|
scope = bernina
|
|
profile = eco
|
|
lazy = true
|
|
|
|
Anything in it can still be overridden on the command line, e.g.
|
|
`eco -s alvra` or `eco jupyterlab -s alvra`.
|
|
|
|
--set-rcfile [PATH] writes -s/-l (and --profile, for console) exactly as
|
|
given on THIS command line into such a file, instead of launching:
|
|
|
|
eco jupyterlab -s alvra --set-rcfile # writes ~/.ecorc
|
|
eco -s alvra --set-rcfile ./.ecorc # writes a project-local one
|
|
|
|
so a later bare `eco` (from that directory, or anywhere if ~/.ecorc)
|
|
picks these settings up automatically.
|
|
"""
|
|
|
|
|
|
def _write_rcfile(args):
|
|
"""Write the resolved command/scope/(profile)/lazy from this invocation
|
|
into an .ecorc file at ``args.set_rcfile`` (see --set-rcfile)."""
|
|
cfg = configparser.ConfigParser()
|
|
section = {
|
|
"command": args.command,
|
|
"scope": args.scope or "",
|
|
"lazy": "true" if args.lazy else "false",
|
|
}
|
|
if hasattr(args, "profile"):
|
|
section["profile"] = args.profile
|
|
cfg["eco"] = section
|
|
path = Path(args.set_rcfile)
|
|
with path.open("w") as fp:
|
|
cfg.write(fp)
|
|
print("eco: wrote {}".format(path), file=sys.stderr)
|
|
for key, value in cfg["eco"].items():
|
|
print(" {} = {}".format(key, value), file=sys.stderr)
|
|
|
|
|
|
def _add_common_args(parser, defaults, scope_default):
|
|
parser.add_argument(
|
|
"-s", "--scope", default=scope_default,
|
|
help="scope name (instrument/beamline), e.g. bernina (default: %(default)s)",
|
|
)
|
|
lazy_grp = parser.add_mutually_exclusive_group()
|
|
lazy_grp.add_argument(
|
|
"-l", "--lazy", dest="lazy", action="store_true", default=defaults["lazy"],
|
|
help="lazy initialisation of the scope (defer device instantiation)"
|
|
+ (" [default]" if defaults["lazy"] else ""),
|
|
)
|
|
lazy_grp.add_argument(
|
|
"--no-lazy", dest="lazy", action="store_false",
|
|
help="disable lazy initialisation (build all devices at startup)"
|
|
+ ("" if defaults["lazy"] else " [default]"),
|
|
)
|
|
parser.add_argument(
|
|
"--set-rcfile", nargs="?", const=str(Path.home() / ".ecorc"), default=None,
|
|
metavar="PATH",
|
|
help="write this invocation's settings into an .ecorc file (PATH, "
|
|
"default: ~/.ecorc) and exit instead of launching. See "
|
|
"'Configuring defaults with .ecorc' below.",
|
|
)
|
|
|
|
|
|
def _add_console_flag(parser, help_on, help_off):
|
|
grp = parser.add_mutually_exclusive_group()
|
|
grp.add_argument(
|
|
"--console", dest="console", action="store_true", default=True,
|
|
help=help_on + " [default]",
|
|
)
|
|
grp.add_argument("--no-console", dest="console", action="store_false", help=help_off)
|
|
|
|
|
|
def _add_namespace_panel_flag(parser):
|
|
grp = parser.add_mutually_exclusive_group()
|
|
grp.add_argument(
|
|
"--namespace-panel", dest="namespace_panel", action="store_true", default=True,
|
|
help="dockable Namespace launcher panel [default]",
|
|
)
|
|
grp.add_argument(
|
|
"--no-namespace-panel", dest="namespace_panel", action="store_false",
|
|
help="skip the Namespace launcher panel -- -s's namespace is still built/"
|
|
"usable (in the console, and to reopen a --workspace's widgets), just "
|
|
"without the browsable panel taking up screen space",
|
|
)
|
|
|
|
|
|
def main(argv=None):
|
|
if argv is None:
|
|
argv = sys.argv[1:]
|
|
defaults, ecorc = _load_defaults()
|
|
argv = _normalize_argv(argv, defaults["command"])
|
|
|
|
parser = argparse.ArgumentParser(
|
|
prog="eco",
|
|
description="Launch eco in an IPython console, a Qt desktop "
|
|
"workbench, a Voila dashboard, or JupyterLab.",
|
|
epilog=_EPILOG,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
subparsers = parser.add_subparsers(dest="command")
|
|
|
|
p_console = subparsers.add_parser("console", help="Interactive IPython session (default).")
|
|
_add_common_args(p_console, defaults, scope_default=defaults["scope"])
|
|
p_console.add_argument(
|
|
"--profile", default=defaults["profile"],
|
|
help="IPython profile for the console (default: %(default)s)",
|
|
)
|
|
|
|
p_desktop = subparsers.add_parser("desktop", help="Qt desktop workbench.")
|
|
# Same scope default as every other subcommand (.ecorc's scope=, or
|
|
# the builtin "bernina") -- .ecorc's choice must apply here too, even
|
|
# when .ecorc's own `command=` isn't "desktop" (e.g. bare `eco`
|
|
# defaults to console, but `eco desktop` should still pick up .ecorc's
|
|
# scope). Explicitly pass -s "" (or set `scope =` blank in .ecorc) for
|
|
# a plain console with no Namespace launcher panel (see
|
|
# eco.widgets.desktop_app) instead of a scope.
|
|
_add_common_args(p_desktop, defaults, scope_default=defaults["scope"])
|
|
_add_console_flag(
|
|
p_desktop,
|
|
help_on="embedded IPython console",
|
|
help_off="skip the embedded console (Namespace launcher panel only, if -s is given)",
|
|
)
|
|
_add_namespace_panel_flag(p_desktop)
|
|
p_desktop.add_argument(
|
|
"--theme", choices=["dark", "light", "none"], default=None,
|
|
help="modern skin, or 'none' for native OS style (default: dark)",
|
|
)
|
|
p_desktop.add_argument(
|
|
"--workspace", default=None, metavar="PATH",
|
|
help="load this workspace file on startup (dock layout + which "
|
|
"namespace entries to reopen) -- see the desktop window's "
|
|
"Workspace menu, 'Save Startup Script...'",
|
|
)
|
|
|
|
p_webapp = subparsers.add_parser("webapp", help="Voila dashboard (read-only widgets).")
|
|
_add_common_args(p_webapp, defaults, scope_default=defaults["scope"])
|
|
|
|
p_jupyterlab = subparsers.add_parser("jupyterlab", help="JupyterLab, with a preloaded console/kernel.")
|
|
_add_common_args(p_jupyterlab, defaults, scope_default=defaults["scope"])
|
|
_add_console_flag(
|
|
p_jupyterlab,
|
|
help_on="also open a real `jupyter console` on the preloaded eco-<scope> kernel",
|
|
help_off="just open JupyterLab, no separate console (the eco-<scope> kernel is still registered/default if -s is given)",
|
|
)
|
|
|
|
p_box_server = subparsers.add_parser(
|
|
"box-server",
|
|
help="Hold this console's connection to the physical manual-control "
|
|
"box (see eco.manual_control.control_box's manual).",
|
|
)
|
|
# Same -s/--scope/-l/--set-rcfile as every other subcommand; -l/--lazy is
|
|
# accepted but unused (box-server never calls init_all -- see the box's
|
|
# own lazy-tree philosophy in control_box.py's manual) so that
|
|
# --set-rcfile / .ecorc keep working uniformly across all subcommands.
|
|
_add_common_args(p_box_server, defaults, scope_default=defaults["scope"])
|
|
p_box_server.add_argument("--box-host", default="ecobox",
|
|
help="the box to call (default: %(default)s)")
|
|
p_box_server.add_argument("--box-port", type=int, default=8791)
|
|
p_box_server.add_argument("--token-file", default="~/.eco/pendant_token")
|
|
p_box_server.add_argument("--host", default="0.0.0.0",
|
|
help="admin/health HTTP bind address (default: %(default)s)")
|
|
p_box_server.add_argument("--port", type=int, default=8092,
|
|
help="admin/health HTTP port (default: %(default)s)")
|
|
|
|
args = parser.parse_args(argv)
|
|
|
|
if args.set_rcfile is not None:
|
|
_write_rcfile(args)
|
|
return
|
|
|
|
if ecorc is not None:
|
|
print("eco: using defaults from {}".format(ecorc), file=sys.stderr)
|
|
|
|
if args.command == "console":
|
|
_run_console(args)
|
|
elif args.command == "desktop":
|
|
_run_desktop(args)
|
|
elif args.command == "webapp":
|
|
_run_webapp(args)
|
|
elif args.command == "jupyterlab":
|
|
_run_jupyterlab(args)
|
|
elif args.command == "box-server":
|
|
_run_box_server(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|