"""Process-wide linked selection and filter, so the views stop being islands.
:mod:`spacr.selection` holds the logic and knows nothing about Qt. This module
is the thin part that makes it *shared*: one object per process that every open
view subscribes to, so lassoing a cluster in the UMAP highlights the same cells
on the plate heatmap, in the measurement table and in the crop grid.
Why a singleton rather than passing a model around
--------------------------------------------------
The views are constructed independently by ``AppScreen`` as the user opens
tabs; none of them owns another, and there is no common parent below the main
window to hang a shared model off. A process-wide accessor is the same shape
:func:`spacr.qt.bridge.registry` already uses for run state, for the same
reason, and it means a view added later joins the conversation by importing one
function.
The subscription rule
---------------------
**Views must disconnect in** ``closeEvent``. This object outlives every screen,
and holds plain references to whatever connected to it. A lambda would keep a
destroyed page alive as a receiver — the exact leak
:class:`spacr.qt.widgets.home.HomePage` documents for the run registry — so
connect bound methods and drop them on close.
:class:`LinkedView` is that rule written down once. A view joins with three
lines rather than re-deriving the echo-suppression and disconnect dance::
class UmapView(LinkedView, QWidget): # 1. mix it in, FIRST
def __init__(self, parent=None):
super().__init__(parent)
self.link_selection("umap") # 2. subscribe + name yourself
def closeEvent(self, event):
self.unlink_selection() # 3. and let go
super().closeEvent(event)
# then override only what it cares about
def on_linked_selection_changed(self, selection):
self._repaint_highlight(selection)
and publishes with ``self.publish_selection(frame_or_keys)``, which stamps the
view's own name so the view does not repaint for the echo of its own lasso.
Opening objects somewhere else
------------------------------
Linking answers "we are all looking at the same population". It does not
answer "show me *these twelve* crops, in this order, because a model got them
wrong" — that is a jump to another view, not a change of shared state, so it
travels as a one-shot :class:`~spacr.selection.ObjectRequest` through
:func:`open_objects` instead.
The point of routing it is that neither end imports the other. A scatter plot
that wants a crop shown, and a confusion matrix that wants a cell's errors
shown, both call :func:`open_objects`; the Annotate screen calls
:func:`register_object_opener` once. Neither plot has to know Annotate exists,
and Annotate does not grow a method per caller.
"""
from __future__ import annotations
from dataclasses import replace
from typing import TYPE_CHECKING, Any, Callable, Dict, Mapping, Optional, Tuple
from PySide6.QtCore import QObject, Signal
from ..selection import DataFilter, ObjectRequest, Selection, as_key_index
if TYPE_CHECKING:
import pandas as pd
__all__ = [
"LinkedSelection",
"LinkedView",
"NoObjectOpener",
"ObjectOpener",
"DEFAULT_OPEN_KIND",
"linked_selection",
"open_objects",
"open_request",
"register_object_opener",
"unregister_object_opener",
"has_object_opener",
"object_opener_kinds",
]
#: The destination :func:`open_objects` routes to when the caller does not say
#: otherwise. "Show me these objects" almost always means "show me the crops",
#: and the crop grid is Annotate.
DEFAULT_OPEN_KIND = "annotate"
#: What :func:`register_object_opener` takes: one call, one request, any
#: return value (the screen that opened, ``True``, or ``None`` — it is passed
#: back to the caller unchanged).
ObjectOpener = Callable[[ObjectRequest], Any]
[docs]
class NoObjectOpener(LookupError):
"""Nothing is registered to open objects of the requested kind.
A caller for which a missing destination is expected, such as an optional
context-menu action, should call :func:`has_object_opener` first.
"""
[docs]
class LinkedSelection(QObject):
"""The shared filter and selection every linked view reads.
Signals:
* filter_changed() — the population narrowed or widened
* selection_changed() — the highlighted subset moved
* objects_opened(request) — an :class:`~spacr.selection.ObjectRequest` was
routed somewhere. Emitted after the opener returned, so a view following
along never chases a jump that failed.
The first two are separate signals because they cost different amounts to
honour. A filter change means a view has to re-query and re-lay-out; a
selection change usually means it only has to repaint. Collapsing them into
one ``changed`` would make every lasso trigger a full reload of a
million-row table.
The opener registry lives on the instance rather than in a module global
so that a test — or a second window — gets its own routing table by
constructing its own :class:`LinkedSelection`.
:param parent: parent widget.
"""
filter_changed = Signal()
selection_changed = Signal()
objects_opened = Signal(object)
def __init__(self, parent=None):
"""Create the shared link with no filter, no selection and no openers.
:param parent: parent object, or ``None``.
"""
super().__init__(parent)
self._filter = DataFilter()
self._selection = Selection.none()
self._openers: Dict[str, ObjectOpener] = {}
@property
[docs]
def filter(self) -> DataFilter:
"""The active filter. Mutate through :meth:`set_filter`, not in place.
Returned rather than copied because a copy per read would be wasteful
on a hot path, but mutating it directly will not emit — which is the
one way to get views showing different populations.
"""
return self._filter
[docs]
def set_filter(self, data_filter: DataFilter) -> None:
"""Replace the filter and tell every view, even if it looks the same.
No equality short-circuit on purpose. ``DataFilter`` holds a list of
dataclasses, and a caller that mutated one in place then handed the
same object back would compare equal to itself and emit nothing,
leaving the views showing a population that no longer matches the
controls.
:param data_filter: the new shared filter; stored as given and
``filter_changed`` is emitted.
"""
self._filter = data_filter
self.filter_changed.emit()
[docs]
def clear_filter(self) -> None:
"""Drop the shared filter, showing every row again.
BROADCAST, like any other filter change: every linked view is showing
a subset because of this filter, so clearing it silently would leave
them all filtered by something no longer there.
"""
self.set_filter(DataFilter())
@property
[docs]
def selection(self) -> Selection:
"""What is currently selected across the linked views.
:returns: the selection.
"""
return self._selection
[docs]
def set_selection(self, selection: Selection) -> None:
"""Publish a new highlighted subset.
``selection.source`` names the view that made it, so a view can ignore
the echo of its own selection rather than re-applying what it just
drew — which otherwise costs a repaint per view per lasso, and can
loop if a view normalises what it publishes.
:param selection: the new shared selection; stored as given and
``selection_changed`` is emitted.
"""
self._selection = selection
self.selection_changed.emit()
[docs]
def clear_selection(self) -> None:
"""Return to the resting state.
Distinct from selecting nothing: :class:`spacr.selection.Selection`
keeps "no selection" and "an empty selection" apart so views can draw
the resting state differently from a lasso that caught nothing.
"""
self.set_selection(Selection.none())
[docs]
def select_frame(self, frame: pd.DataFrame, source: str = "",
*, timelapse: bool = False) -> None:
"""Convenience: publish the rows of ``frame`` as the selection.
:param frame: the rows to publish, turned into a selection by
:meth:`spacr.selection.Selection.from_frame`.
"""
self.set_selection(
Selection.from_frame(frame, source=source, timelapse=timelapse))
[docs]
def visible(self, frame: pd.DataFrame) -> pd.DataFrame:
"""``frame`` narrowed by the active filter.
The one call a view needs to honour the filter. Selection is deliberately
NOT applied — a selection highlights, it does not hide, and a view that
dropped unselected rows would make the lasso destructive.
:param frame: the rows to narrow; the active filter's ``apply`` is
called on it.
"""
return self._filter.apply(frame)
[docs]
def register_object_opener(self, kind: str,
fn: ObjectOpener) -> Optional[ObjectOpener]:
"""Offer to open objects of ``kind``, and return whoever had it before.
Registering a kind that is already taken REPLACES it, because that is
what re-opening a screen looks like: the second Annotate is the live
one and the first is on its way out. The displaced opener is returned
rather than dropped so a caller that wants to chain or restore it can.
The matching :meth:`unregister_object_opener` is identity-checked for
the same reason — a screen closing must not take the registration of
the screen that replaced it.
:param kind: the destination name, stripped of surrounding whitespace;
must not be blank.
:param fn: the callable that receives each
:class:`~spacr.selection.ObjectRequest` for ``kind``; must be
callable.
:raises ValueError: on a blank kind.
:raises TypeError: if ``fn`` is not callable — caught here, where the
registration is, rather than at the click that would have used it.
"""
key = str(kind).strip()
if not key:
raise ValueError("an object opener needs a non-blank kind")
if not callable(fn):
raise TypeError(
f"object opener for {key!r} is not callable "
f"({type(fn).__name__})")
previous = self._openers.get(key)
self._openers[key] = fn
return previous
[docs]
def unregister_object_opener(self, kind: str,
fn: Optional[ObjectOpener] = None) -> bool:
"""Withdraw ``kind``; ``True`` if this call is what removed it.
With ``fn`` given, removes it only if that is the opener currently
registered. A closing screen should always pass its own opener: two
Annotate screens opened in a session means the first one's
``closeEvent`` runs *after* the second has registered, and an
unconditional withdrawal would leave the live screen unreachable.
Compared with ``==``, not ``is``. The pattern this module documents —
``register_object_opener("annotate", self.open_object_request)`` in
the constructor and ``unregister_object_opener("annotate",
self.open_object_request)`` in ``closeEvent`` — hands over a
*different bound-method object* each time, because Python builds a
new one on every attribute access. Under an identity check that made
every withdrawal a silent no-op, so the process-wide registry kept a
reference to a destroyed screen and the next ``open_objects`` reached
it. Bound methods compare equal exactly when their ``__self__`` and
``__func__`` match, which is the question being asked; anything that
does not define ``__eq__`` (a lambda, a ``partial``) still falls back
to identity, so the two-screen guarantee above is unchanged.
:param kind: the destination name, stripped of surrounding whitespace
before lookup.
"""
key = str(kind).strip()
current = self._openers.get(key)
if current is None or (fn is not None and current != fn):
return False
del self._openers[key]
return True
[docs]
def has_object_opener(self, kind: str) -> bool:
"""Whether anything is registered for ``kind``.
For greying out a menu entry rather than offering one that raises.
:param kind: the destination name, stripped of surrounding whitespace
before lookup.
"""
return str(kind).strip() in self._openers
[docs]
def object_opener_kinds(self) -> Tuple[str, ...]:
"""Every registered kind, sorted — for diagnostics and menus."""
return tuple(sorted(self._openers))
[docs]
def open_objects(self, keys: Any, *, reason: str,
kind: str = DEFAULT_OPEN_KIND, source: str = "",
timelapse: bool = False,
context: Optional[Mapping[str, Any]] = None) -> Any:
"""Show exactly these objects, wherever ``kind`` is registered.
The instance-level form of the module function of the same name; see
:func:`open_objects` for the argument contract.
:param keys: what to open: a :class:`pandas.DataFrame` carrying the
object key columns, a :class:`~spacr.selection.Selection`, one key
string, or an iterable of key strings, as for :func:`open_objects`.
:param reason: why these objects, in the words the destination will
show; required and non-blank.
"""
return self.open_request(ObjectRequest(
keys=keys, reason=reason, source=source, kind=kind,
timelapse=timelapse, context=context or {}))
[docs]
def open_request(self, request: ObjectRequest) -> Any:
"""Route an already-built request, and return what the opener returned.
Split from :meth:`open_objects` so a request can be built where the
data is — in a worker, off the event loop — and routed later on the
GUI thread, without that hop having to re-list every argument.
The request is NOT published as the shared selection. Opening a subset
somewhere and highlighting it everywhere are separate acts, and doing
both here would wipe the lasso the user opened it from. A receiver
that wants both publishes :meth:`~spacr.selection.ObjectRequest.as_selection`.
:param request: the built :class:`~spacr.selection.ObjectRequest`; an
empty ``kind`` means ``DEFAULT_OPEN_KIND``. It is passed to the
registered opener and then emitted on ``objects_opened``.
:raises NoObjectOpener: if nothing is registered for the kind.
"""
kind = request.kind or DEFAULT_OPEN_KIND
opener = self._openers.get(kind)
if opener is None:
known = ", ".join(self.object_opener_kinds()) or "nothing"
raise NoObjectOpener(
f"nothing is registered to open {kind!r} objects "
f"(registered: {known}). The screen that opens them may not "
f"have been created yet — ask has_object_opener({kind!r}) "
f"before offering the action.")
if request.kind != kind:
request = replace(request, kind=kind)
result = opener(request)
self.objects_opened.emit(request)
return result
[docs]
class LinkedView:
"""What a view mixes in to join the linked selection.
A mixin rather than a base class: the views are already
``QWidget`` subclasses, and this holds no Qt state of its own — no
``__init__``, only class-level defaults — so it composes with any of them.
**List it first** (``class UmapView(LinkedView, QWidget)``), so its methods
win over anything Qt happens to name the same.
The three lines of the contract are in the module docstring. What the
mixin buys over hand-wiring, beyond the typing:
* **Echo suppression.** :meth:`publish_selection` stamps the view's own
name, and the subscriber drops selections carrying it. Without that,
every lasso costs the drawing view a repaint of what it already drew,
and a view that normalises what it publishes oscillates.
* **One disconnect.** :meth:`unlink_selection` is idempotent and
flag-guarded: Qt does not raise on a disconnect that finds nothing, it
prints ``libpyside: Failed to disconnect`` to stderr where no ``except``
can reach it, and a screen closed twice — which Qt does on teardown —
would print one every time.
* **Bound methods only.** The process-wide link outlives every screen; the
slots it holds are bound methods of the view, so Qt severs them when the
widget is destroyed even if a ``closeEvent`` is missed.
"""
#: The name this view publishes under, and the name it ignores the echo
#: of. Set by :meth:`link_selection`.
link_source: str = ""
_link: Optional[LinkedSelection] = None
_link_connected: bool = False
_link_echo: bool = False
[docs]
def link_selection(self, source: str, *,
link: Optional[LinkedSelection] = None,
echo: bool = False) -> LinkedSelection:
"""Subscribe to the shared filter and selection as ``source``.
:param source: this view's name, stamped onto everything it publishes.
:param link: the :class:`LinkedSelection` to join. Defaults to the
process-wide one; a test (or a second window) passes its own.
:param echo: hear your own published selections too. Off by default —
a view that just drew a lasso does not need to be told about it.
:returns: the link, so a caller can keep the reference.
Calling this twice re-subscribes rather than double-subscribing: a
screen that re-links on reload would otherwise get two callbacks per
change, and repaint twice for every lasso for the rest of the session.
"""
if self._link_connected:
self.unlink_selection()
self.link_source = str(source)
self._link_echo = bool(echo)
self._link = link if link is not None else linked_selection()
self._link.filter_changed.connect(self._linked_filter_changed)
self._link.selection_changed.connect(self._linked_selection_changed)
self._link_connected = True
return self._link
[docs]
def unlink_selection(self) -> None:
"""Stop listening. Safe to call on a view that never linked."""
if not self._link_connected:
return
self._link_connected = False
self._link.filter_changed.disconnect(self._linked_filter_changed)
self._link.selection_changed.disconnect(self._linked_selection_changed)
@property
[docs]
def is_linked(self) -> bool:
"""Whether this view is currently subscribed."""
return self._link_connected
@property
[docs]
def link(self) -> LinkedSelection:
"""The link this view is on — the process-wide one until it joins one.
Non-``None`` before :meth:`link_selection`, deliberately: publishing
without subscribing is a legitimate half of the contract (a view that
drives the others but redraws itself), and it should not need to
subscribe just to reach ``visible()``.
"""
return self._link if self._link is not None else linked_selection()
[docs]
def on_linked_filter_changed(self, data_filter: DataFilter) -> None:
"""The shared population moved: re-query and re-lay-out.
Default: nothing, so a view can subscribe for selections alone.
:param data_filter: the shared filter now in force.
"""
[docs]
def on_linked_selection_changed(self, selection: Selection) -> None:
"""The highlighted subset moved: repaint.
Not called for this view's own selections unless it linked with
``echo=True``. Remember that ``selection.keys is None`` is the resting
state — draw it differently from a lasso that caught nothing.
Default: nothing, so a view can subscribe for the filter alone.
:param selection: the shared selection now in force.
"""
[docs]
def publish_selection(self, keys: Any, *,
timelapse: bool = False) -> Selection:
"""Publish ``keys`` as the shared highlight, stamped with this view.
``keys`` is anything :func:`~spacr.selection.as_key_index` takes — the
lassoed rows as a frame, or bare keys.
:param keys: the lassoed rows as a frame, or bare keys; anything
:func:`~spacr.selection.as_key_index` accepts.
"""
selection = Selection(keys=as_key_index(keys, timelapse=timelapse),
source=self.link_source)
self.link.set_selection(selection)
return selection
[docs]
def clear_linked_selection(self) -> None:
"""Return everyone to the resting state (not to an empty selection)."""
self.link.clear_selection()
[docs]
def publish_filter(self, data_filter: DataFilter) -> None:
"""Narrow the shared population from this view.
:param data_filter: the filter to install as the shared one.
"""
self.link.set_filter(data_filter)
[docs]
def linked_visible(self, frame: pd.DataFrame) -> pd.DataFrame:
"""``frame`` narrowed by the shared filter. A selection never hides.
:param frame: the rows to narrow by the shared filter.
"""
return self.link.visible(frame)
[docs]
def open_objects(self, keys: Any, *, reason: str,
kind: str = DEFAULT_OPEN_KIND,
timelapse: bool = False,
context: Optional[Mapping[str, Any]] = None) -> Any:
"""Ask for these objects to be shown, as this view.
The same call as :func:`open_objects` with ``source`` filled in.
:param keys: what to open: a :class:`pandas.DataFrame` carrying the
object key columns, a :class:`~spacr.selection.Selection`, one key
string, or an iterable of key strings, as for :func:`open_objects`.
:param reason: why these objects, in the words the destination will
show; required and non-blank.
"""
return self.link.open_objects(
keys, reason=reason, kind=kind, source=self.link_source,
timelapse=timelapse, context=context)
def _linked_filter_changed(self) -> None:
"""Hand the link's new filter to this view."""
self.on_linked_filter_changed(self.link.filter)
def _linked_selection_changed(self) -> None:
"""Hand the link's new selection to this view, unless this view published it.
A view that published a selection has already applied it, so hearing it
back would be an echo -- and for a view that re-publishes on apply, an
endless one. Echo suppression is switched off by ``_link_echo`` for the
tests that need to see the round trip.
"""
selection = self.link.selection
if (not self._link_echo and self.link_source
and selection.source == self.link_source):
return
self.on_linked_selection_changed(selection)
_LINKED: Optional[LinkedSelection] = None
[docs]
def linked_selection() -> LinkedSelection:
"""The process-wide :class:`LinkedSelection` (created on first use)."""
global _LINKED
if _LINKED is None:
_LINKED = LinkedSelection()
return _LINKED
[docs]
def register_object_opener(kind: str, fn: ObjectOpener) -> Optional[ObjectOpener]:
"""Offer to open objects of ``kind`` process-wide.
The half of the routing contract a *destination* implements. Annotate, in
its constructor::
register_object_opener("annotate", self.open_object_request)
and in ``closeEvent``::
unregister_object_opener("annotate", self.open_object_request)
where ``open_object_request(request: ObjectRequest) -> Any`` is the one
method it has to grow. Everything the caller wanted to say is on the
request: ``request.keys`` (an Index of
:data:`~spacr.selection.OBJECT_KEY_COLUMNS` keys, in the caller's order,
de-duplicated), ``request.reason`` (put it in the header),
``request.source``, ``request.timelapse`` and ``request.context``. The
return value is handed back to the caller unchanged.
:param kind: the destination name, e.g. ``'annotate'``; must not be blank.
:param fn: the opener, called with each
:class:`~spacr.selection.ObjectRequest` for ``kind``; its return value
goes back to the caller.
:returns: the opener this one displaced, or ``None``.
"""
return linked_selection().register_object_opener(kind, fn)
[docs]
def unregister_object_opener(kind: str,
fn: Optional[ObjectOpener] = None) -> bool:
"""Withdraw ``kind`` process-wide; ``True`` if this call removed it.
Pass the opener you registered: with two screens of the same kind open,
the one closing must not withdraw the one that replaced it.
:param kind: the destination name, stripped of surrounding whitespace
before lookup.
"""
return linked_selection().unregister_object_opener(kind, fn)
[docs]
def has_object_opener(kind: str) -> bool:
"""Whether anything process-wide can open ``kind``.
Ask before offering the action, so an unavailable destination is a greyed
menu entry rather than a :class:`NoObjectOpener` on click.
:param kind: the destination name, stripped of surrounding whitespace
before lookup.
"""
return linked_selection().has_object_opener(kind)
[docs]
def object_opener_kinds() -> Tuple[str, ...]:
"""Every kind registered process-wide, sorted."""
return linked_selection().object_opener_kinds()
[docs]
def open_objects(keys: Any, *, reason: str, kind: str = DEFAULT_OPEN_KIND,
source: str = "", timelapse: bool = False,
context: Optional[Mapping[str, Any]] = None) -> Any:
"""Show exactly these objects, wherever ``kind`` is registered.
The half of the routing contract a *caller* uses. A scatter plot::
open_objects(row, reason="clicked in the UMAP", source="umap")
A confusion-matrix cell, worst errors first::
open_objects(errors_sorted_by_confidence,
reason="predicted infected · annotated uninfected",
source="classifier_evaluation",
context={"scores": scores_by_key})
Neither imports Annotate, and Annotate grows no method per caller.
:param keys: what to open — a :class:`pandas.DataFrame` carrying
:data:`~spacr.selection.OBJECT_KEY_COLUMNS`, a
:class:`~spacr.selection.Selection`, one key string, or an iterable of
key strings. Order is kept and duplicates dropped, so "worst first"
survives the trip. See :func:`~spacr.selection.as_key_index`.
:param reason: why these objects, in the words the destination will show.
Required and non-blank.
:param kind: the destination; defaults to :data:`DEFAULT_OPEN_KIND`.
:param source: the view asking, for the destination's header.
:param timelapse: the keys carry a timepoint.
:param context: extras for the destination (per-key scores, a column to
annotate into). Copied; the destination cannot mutate the caller's.
:returns: whatever the opener returned, unchanged.
:raises NoObjectOpener: nothing is registered for ``kind``.
:raises ValueError: blank ``reason``, or a resting ``Selection``.
:raises TypeError: ``keys`` is not something that names objects.
An empty ``keys`` is not an error: a confusion-matrix cell with no errors
in it is a real answer, and the destination showing "0 objects · <reason>"
beats an exception every caller has to guard.
"""
return linked_selection().open_objects(
keys, reason=reason, kind=kind, source=source, timelapse=timelapse,
context=context)
[docs]
def open_request(request: ObjectRequest) -> Any:
"""Route an already-built :class:`~spacr.selection.ObjectRequest`.
For building the request where the data is — off the event loop — and
routing it on the GUI thread.
:param request: the built request, routed to the opener registered for its
``kind``.
"""
return linked_selection().open_request(request)