Source code for phenotypic.gui.shell._launcher

"""Shell launcher + ``phenotypic-gui`` console-script entry point.

This module ships two callables:

    * :func:`launch_gui` — programmatic boot used by the ``__main__`` module
      and downstream tests. Mirrors
      :func:`phenotypic.gui.results_viewer.launch_results_viewer` for
      consistency with the existing standalone tools.
    * :func:`main` — argparse front-end wired into ``[project.scripts]``
      (``phenotypic-gui = phenotypic.gui.shell._launcher:main``) and into
      ``python -m phenotypic.gui``.

The launcher refuses to boot if ``--root`` does not exist or is not a
directory; we catch :class:`ValueError`/``FileNotFoundError``/etc. from
:meth:`SandboxRoot.from_path` and surface a clean error rather than a
stack trace.

CLI defaults (host, port, debug flag) and the logging format come from
:mod:`phenotypic.gui._config` so they stay in lock-step with the
standalone debug launchers.
"""
from __future__ import annotations

import argparse
import logging
import sys
from pathlib import Path
from typing import Optional, Sequence

from phenotypic.gui._config import (
    DEFAULT_HOST,
    DEFAULT_PORT,
    DEFAULT_URL_PREFIX,
    SSH_TUNNEL_HINT,
    TITLE_HUB,
    add_launcher_args,
    configure_launcher_logging,
    print_launcher_banner,
)
from phenotypic.gui.shell._app import create_app
from phenotypic.gui.shell._sandbox import SandboxRoot
from phenotypic.gui.shell._startup import StartupReporter, should_use_rich

logger = logging.getLogger(__name__)

__all__ = ["launch_gui", "main"]

#: Number of bar segments the launcher advances: core import (already done),
#: sandbox resolution, hub composition. See :class:`StartupReporter`.
_STARTUP_STEPS = 3


def _core_import_elapsed() -> float | None:
    """Seconds spent importing the ``phenotypic`` library before ``main()``.

    Measured against :data:`phenotypic._startup_perf.IMPORT_STARTED_AT`,
    which is stamped the moment the (heavy) package import begins — the only
    point early enough to capture it, since the console-script import chain
    runs before any launcher code. Returns ``None`` if the stamp is absent
    (e.g. a stubbed import in tests).
    """
    import time

    try:
        import phenotypic

        return time.perf_counter() - phenotypic._IMPORT_STARTED_AT
    except Exception:  # pragma: no cover - defensive
        return None


[docs] def launch_gui( root: Path | str = Path.cwd(), host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, debug: bool = False, url_prefix: str = DEFAULT_URL_PREFIX, *, reporter: StartupReporter | None = None, ) -> None: """Boot the unified PhenoTypic GUI shell. Args: root: Sandbox root. Strings accepted for ergonomics. Resolved to an absolute :class:`pathlib.Path` and frozen for the lifetime of the process. Defaults to the current working directory. host: Interface to bind. :data:`DEFAULT_HOST` keeps the server loopback-only — pair with SSH port forwarding for remote access. ``0.0.0.0`` exposes it on the network (not recommended without authentication; cloud mode is a non-goal in v1). port: TCP port. Defaults to :data:`DEFAULT_PORT`. debug: Run Dash in debug mode (auto-reload + verbose tracebacks). Defaults to ``False``. url_prefix: Browser-visible path prefix for path-stripping reverse proxies such as Open OnDemand. Defaults to ``"/"``. reporter: Optional :class:`StartupReporter` driving staged progress feedback (sandbox resolution + hub composition). ``None`` (default — the programmatic boot path) skips progress reporting entirely and just resolves, composes, and serves. Raises: FileNotFoundError: If ``root`` does not exist. NotADirectoryError: If ``root`` exists but is not a directory. RuntimeError: On a symlink loop encountered while resolving root. """ if reporter is None: sandbox = SandboxRoot.from_path(root) app = create_app(sandbox, url_prefix=url_prefix) else: with reporter: reporter.record_done( "Core library loaded", reporter.import_elapsed ) with reporter.stage("Resolving sandbox root"): sandbox = SandboxRoot.from_path(root) with reporter.stage("Composing GUI hub"): app = create_app( sandbox, url_prefix=url_prefix, progress=reporter.detail, ) # ``sandbox``/``app`` are always bound here: the ``with`` bodies run # unless ``reporter`` raises, in which case we never reach this line. print_launcher_banner( title=TITLE_HUB, host=host, port=port, root=sandbox.root, url_prefix=url_prefix, ) app.run(host=host, port=port, debug=debug)
def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="phenotypic-gui", description=( "Launch the unified PhenoTypic GUI hub (pipeline builder, " "results viewer, and run console under one URL). " f"Reach the UI by SSH-tunnelling the chosen port (`{SSH_TUNNEL_HINT}`)." ), ) parser.add_argument( "--root", type=Path, default=Path.cwd(), help=( "Sandbox root. The GUI's file browser is restricted to this " "directory. Defaults to the current working directory." ), ) add_launcher_args(parser) return parser
[docs] def main(argv: Optional[Sequence[str]] = None) -> int: """Console-script entry point. Returns: Process exit code (0 = clean shutdown, non-zero = startup failure). """ args = _build_parser().parse_args(argv) configure_launcher_logging(debug=args.debug) reporter = StartupReporter( total_steps=_STARTUP_STEPS, use_rich=should_use_rich(debug=args.debug), import_elapsed=_core_import_elapsed(), ) try: launch_gui( root=args.root, host=args.host, port=args.port, debug=args.debug, url_prefix=args.url_prefix, reporter=reporter, ) except (FileNotFoundError, NotADirectoryError, RuntimeError) as exc: print(f"phenotypic-gui: {exc}", file=sys.stderr) return 2 return 0