phenotypic.gui.shell package#

Unified GUI shell — Dash hub composer.

The shell mounts builder, results_viewer, and run_console under one URL via werkzeug.middleware.dispatcher.DispatcherMiddleware and registers the sandbox JSON API + /runs/ static blueprint on its own Flask server. See GUI_SPEC_V1.md and docs/source/user_guide/gui.rst (Phase 8).

Public API:
  • SandboxRoot — sandbox primitive (Phase 1).

  • ToolSession — lifecycle wrapper (Phase 1).

  • create_app() — composed shell app factory (Phase 3 standalone; Phase 5 will compose sub-apps via DispatcherMiddleware).

  • launch_gui() — convenience launcher (Phase 3).

class phenotypic.gui.shell.SandboxRoot(root: Path)[source]#

Bases: object

Frozen-at-launch sandbox containment primitive.

Parameters:

root (Path)

root#

Absolute, fully-resolved directory the GUI is allowed to read from. Symlinks in the path are resolved at construction time so contains / resolve comparisons are stable for the lifetime of the process.

Type:

pathlib.Path

classmethod from_path(root: str | PathLike[str]) SandboxRoot[source]#

Construct a sandbox from a user-supplied root path.

Resolves symlinks and verifies the root exists and is a directory.

Parameters:

root (str | PathLike[str]) – A directory path. May be relative; will be resolved.

Returns:

A SandboxRoot with .root fully resolved and absolute.

Raises:
  • FileNotFoundError – If root does not exist.

  • NotADirectoryError – If root exists but is not a directory.

  • RuntimeError – If root resolution encounters a symlink loop (CPython’s Path.resolve() raises RuntimeError for cycles, not OSError). The launcher catches and re-presents this as a startup error rather than a stack trace.

Return type:

SandboxRoot

contains(candidate: str | PathLike[str]) bool[source]#

Return True iff candidate resolves inside the sandbox.

Convenience predicate that wraps resolve and swallows the ValueError raised on escapes. Useful for “should I render this at all?” checks where raising would be inconvenient.

Parameters:

candidate (str | PathLike[str])

Return type:

bool

list_children(directory: str | PathLike[str] | None = None, *, include_hidden: bool = False, include_external_symlinks: bool = False) Iterator[Path][source]#

Yield direct children of directory (default: self.root).

Parameters:
  • directory (str | PathLike[str] | None) – Path to list. Defaults to the sandbox root. Validated via resolve first; out-of-root paths raise ValueError.

  • include_hidden (bool) – Yield dotfiles when True. Default False matches the sidebar’s “Hidden files” toggle (off by default).

  • include_external_symlinks (bool) – Yield symlinks whose targets escape self.root when True. Default False matches the sidebar’s “External symlinks” toggle. Non-symlink children are unaffected.

Yields:

Absolute Path objects, one per child. Symlinks are yielded unless they target outside the sandbox and the toggle is off.

Raises:
  • ValueError – If directory resolves outside the sandbox.

  • PermissionError – If the directory cannot be listed; callers may choose to swallow this and surface the bad_perms capability badge instead.

Return type:

Iterator[Path]

resolve(candidate: str | PathLike[str]) Path[source]#

Resolve candidate to an absolute path inside the sandbox.

candidate may be absolute or relative. Relative paths are joined against self.root. Symlinks are followed; the resulting absolute path is verified to be inside self.root. This catches both ..-style traversal and symlinks pointing outside the sandbox.

Parameters:

candidate (str | PathLike[str]) – A path supplied by an untrusted source (URL, query string, JSON body).

Returns:

Absolute, fully-resolved Path guaranteed to be inside (or equal to) self.root.

Raises:

ValueError – If candidate resolves outside self.root or follows a symlink that escapes self.root.

Return type:

Path

root: Path#
class phenotypic.gui.shell.ToolSession(name: str, *, build: Callable[[], T], teardown: Callable[[T], None] | None = None)[source]#

Bases: Generic[T]

Lazy, releasable wrapper around a heavy tool’s state object.

ToolSession is generic over the state type T (e.g. dash.Dash for the viewer, OutputRoot for measurement loading). The shell constructs a session at boot but does NOT call build — that happens on first get().

Parameters:
  • name (str) – Human-readable label for log lines and __repr__.

  • build (Callable[[], T]) – Zero-argument callable that constructs the heavy state. Called once per release cycle, under self._lock.

  • teardown (Callable[[T], None] | None) – Optional cleanup callable invoked with the (now-detached) state after the session has cleared its reference. Runs outside the session’s lock; concurrent get calls may have already triggered a rebuild before teardown returns. Therefore teardown must only touch state owned by the released object, never shared global state that the rebuild repopulates — e.g. clearing keys on the released viewer_app.server.config is fine, but clearing keys on a process-wide singleton (shell_app.server.config) is a bug because the rebuild re-installs them. Defaults to a no-op.

Example

>>> from phenotypic.gui.shell._session import ToolSession
>>> def _build():
...     return {"big_dataframe": "loaded"}
>>> session = ToolSession("viewer", build=_build)
>>> session.get()  # triggers _build()
{'big_dataframe': 'loaded'}
>>> session.release()
>>> session.get()  # _build() runs again
{'big_dataframe': 'loaded'}
get() T[source]#

Return the live state, building it on first access.

Acquires self._lock so concurrent first-access calls don’t both run build. Updates _last_access to the current monotonic time before invoking build so that the idle daemon (which reads _last_access lock-free) cannot observe a freshly-set _state paired with a stale _last_access and spuriously decide to release.

Return type:

T

idle_seconds() float[source]#

Seconds since the last get or touch.

Returns 0.0 when the session is unbuilt — there is nothing to release. Reading _state without the lock is intentional; a race only changes the answer by one polling cycle.

Return type:

float

is_built() bool[source]#

True iff the session currently holds built state.

Return type:

bool

release() None[source]#

Drop the in-memory state. Idempotent.

Runs teardown(state) first, then drops the reference and calls gc.collect(). The next get will rebuild from scratch. Held under self._lock so an in-flight get cannot observe a torn-down state.

Return type:

None

set_build(build: Callable[[], T]) None[source]#

Replace the build callable and drop any existing state.

Used by hand-off endpoints (e.g. the viewer’s /sandbox/api/viewer/output-root) that need the next get to construct the heavy state with new arguments. The swap and the release are performed under self._lock so an in-flight get cannot observe a half-swapped state, and the next get is guaranteed to call the freshly-installed build.

teardown runs outside the lock with the previously-held state, following the same contract as release().

Parameters:

build (Callable[[], T]) – The new zero-argument constructor for the heavy state.

Return type:

None

touch() None[source]#

Bump activity timestamp without forcing a build.

Called by Flask blueprints (/runs/..., /sandbox/api/...) whose hits indicate the user is actively using the tool. Without touch the idle daemon would release tools while the user is reading an iframed dashboard (the dashboard polls files via the /runs/ blueprint, NOT through Dash callbacks).

Lock-free by design — the only mutation is a 64-bit float write.

Return type:

None

phenotypic.gui.shell.create_app(sandbox: SandboxRoot, *, url_prefix: str = '/', viewer_session: ToolSession[Any] | None = None, idle_release_seconds: float = 900.0, start_idle_thread: bool | None = None, progress: Callable[[str], None] | None = None) Dash[source]#

Build the unified GUI hub Dash app.

Phase 5 default: composes the full hub (shell + builder + viewer via ToolSession + run console) and returns the shell’s Dash app with its server.wsgi_app replaced by a DispatcherMiddleware. Tests can hit any HTTP route through app.server.test_client() (which goes through wsgi_app).

Backwards-compat for Phase 3 tests: passing viewer_session explicitly opts out of full composition — the call returns just the shell Dash app with the API + runs blueprints registered against the supplied session. Phase 3 lifecycle tests rely on this to inject a stub session and assert touch() is called.

Parameters:
  • sandbox (SandboxRoot) – Frozen-at-launch sandbox root.

  • url_prefix (str) – Reserved for future composer nesting. Hub keeps it at "/" since the shell Dash is itself the dispatcher’s fallback. Standalone shell launches accept any value.

  • viewer_session (ToolSession[Any] | None) – Phase 3 escape hatch — pass to test the shell in isolation against a specific session. When set, create_app returns just the shell Dash without composing the sub-apps. None (default) triggers the full hub composition.

  • idle_release_seconds (float) – Forwarded to compose_hub().

  • start_idle_thread (bool | None) – Forwarded to compose_hub(). When None (default) the daemon is started under production launch but skipped under pytest (PYTEST_CURRENT_TEST in env). Tests that want the daemon explicitly should pass True.

  • progress (Callable[[str], None] | None) – Optional per-sub-app progress callback forwarded to compose_hub() (the launcher passes StartupReporter.detail()). Ignored on the Phase 3 viewer_session escape-hatch path.

Returns:

Configured dash.Dash instance. app.run() (or Werkzeug run_simple) starts the unified server.

Return type:

Dash

phenotypic.gui.shell.launch_gui(root: Path | str = PosixPath('/home/runner/work/PhenoTypic/PhenoTypic/docs'), host: str = '127.0.0.1', port: int = 8050, debug: bool = False, url_prefix: str = '/', *, reporter: StartupReporter | None = None) None[source]#

Boot the unified PhenoTypic GUI shell.

Parameters:
  • root (Path | str) – Sandbox root. Strings accepted for ergonomics. Resolved to an absolute pathlib.Path and frozen for the lifetime of the process. Defaults to the current working directory.

  • host (str) – Interface to bind. 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 (int) – TCP port. Defaults to DEFAULT_PORT.

  • debug (bool) – Run Dash in debug mode (auto-reload + verbose tracebacks). Defaults to False.

  • url_prefix (str) – Browser-visible path prefix for path-stripping reverse proxies such as Open OnDemand. Defaults to "/".

  • reporter (StartupReporter | None) – Optional 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:
Return type:

None

phenotypic.gui.shell.main(argv: Sequence[str] | None = None) int[source]#

Console-script entry point.

Returns:

Process exit code (0 = clean shutdown, non-zero = startup failure).

Parameters:

argv (Sequence[str] | None)

Return type:

int