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 viaDispatcherMiddleware).launch_gui()— convenience launcher (Phase 3).
- class phenotypic.gui.shell.SandboxRoot(root: Path)[source]#
Bases:
objectFrozen-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/resolvecomparisons are stable for the lifetime of the process.- Type:
- 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
SandboxRootwith.rootfully resolved and absolute.- Raises:
FileNotFoundError – If
rootdoes not exist.NotADirectoryError – If
rootexists but is not a directory.RuntimeError – If
rootresolution encounters a symlink loop (CPython’sPath.resolve()raisesRuntimeErrorfor cycles, notOSError). The launcher catches and re-presents this as a startup error rather than a stack trace.
- Return type:
- contains(candidate: str | PathLike[str]) bool[source]#
Return
Trueiffcandidateresolves inside the sandbox.Convenience predicate that wraps
resolveand swallows theValueErrorraised on escapes. Useful for “should I render this at all?” checks where raising would be inconvenient.
- 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
resolvefirst; out-of-root paths raiseValueError.include_hidden (bool) – Yield dotfiles when
True. DefaultFalsematches the sidebar’s “Hidden files” toggle (off by default).include_external_symlinks (bool) – Yield symlinks whose targets escape
self.rootwhenTrue. DefaultFalsematches the sidebar’s “External symlinks” toggle. Non-symlink children are unaffected.
- Yields:
Absolute
Pathobjects, one per child. Symlinks are yielded unless they target outside the sandbox and the toggle is off.- Raises:
ValueError – If
directoryresolves outside the sandbox.PermissionError – If the directory cannot be listed; callers may choose to swallow this and surface the
bad_permscapability badge instead.
- Return type:
- resolve(candidate: str | PathLike[str]) Path[source]#
Resolve
candidateto an absolute path inside the sandbox.candidatemay be absolute or relative. Relative paths are joined againstself.root. Symlinks are followed; the resulting absolute path is verified to be insideself.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
Pathguaranteed to be inside (or equal to)self.root.- Raises:
ValueError – If
candidateresolves outsideself.rootor follows a symlink that escapesself.root.- Return type:
- 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.
ToolSessionis generic over the state typeT(e.g.dash.Dashfor the viewer,OutputRootfor measurement loading). The shell constructs a session at boot but does NOT callbuild— that happens on firstget().- 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
getcalls may have already triggered a rebuild beforeteardownreturns. Thereforeteardownmust only touch state owned by the released object, never shared global state that the rebuild repopulates — e.g. clearing keys on the releasedviewer_app.server.configis 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._lockso concurrent first-access calls don’t both runbuild. Updates_last_accessto the current monotonic time before invokingbuildso that the idle daemon (which reads_last_accesslock-free) cannot observe a freshly-set_statepaired with a stale_last_accessand spuriously decide to release.- Return type:
T
- idle_seconds() float[source]#
Seconds since the last
getortouch.Returns 0.0 when the session is unbuilt — there is nothing to release. Reading
_statewithout the lock is intentional; a race only changes the answer by one polling cycle.- Return type:
- release() None[source]#
Drop the in-memory state. Idempotent.
Runs
teardown(state)first, then drops the reference and callsgc.collect(). The nextgetwill rebuild from scratch. Held underself._lockso an in-flightgetcannot 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 nextgetto construct the heavy state with new arguments. The swap and the release are performed underself._lockso an in-flightgetcannot observe a half-swapped state, and the nextgetis guaranteed to call the freshly-installedbuild.teardownruns outside the lock with the previously-held state, following the same contract asrelease().- 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. Withouttouchthe 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 itsserver.wsgi_appreplaced by aDispatcherMiddleware. Tests can hit any HTTP route throughapp.server.test_client()(which goes throughwsgi_app).Backwards-compat for Phase 3 tests: passing
viewer_sessionexplicitly 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 asserttouch()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_appreturns 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(). WhenNone(default) the daemon is started under production launch but skipped under pytest (PYTEST_CURRENT_TESTin env). Tests that want the daemon explicitly should passTrue.progress (Callable[[str], None] | None) – Optional per-sub-app progress callback forwarded to
compose_hub()(the launcher passesStartupReporter.detail()). Ignored on the Phase 3viewer_sessionescape-hatch path.
- Returns:
Configured
dash.Dashinstance.app.run()(or Werkzeugrun_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.Pathand frozen for the lifetime of the process. Defaults to the current working directory.host (str) – Interface to bind.
DEFAULT_HOSTkeeps the server loopback-only — pair with SSH port forwarding for remote access.0.0.0.0exposes 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
StartupReporterdriving 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
rootdoes not exist.NotADirectoryError – If
rootexists but is not a directory.RuntimeError – On a symlink loop encountered while resolving root.
- Return type:
None