"""Assign evidence-based maturity labels to Qt applications.
Application maturity is assessed from tests, command-line support or a stated
GUI-only scope, documentation, tutorials, and use by other spaCR components.
The labels have the following meanings:
``stable``
The application has a working pipeline or library, broad test coverage,
and user documentation.
``beta``
The application is functional and tested but has less documentation or
operational use than a stable application.
The :func:`apply` function updates :data:`spacr.qt.app.APP_STAGE` from the
assessments below. An application without an assessment or an explicitly
declared stage is assigned :data:`UNASSESSED_STAGE` (``"alpha"``). Explicit
beta labels are retained, while recorded assessments take precedence.
:data:`PROMOTIONS` contains applications whose labels changed;
:data:`AFFIRMED` contains reviewed applications whose labels were already
appropriate.
"""
from __future__ import annotations
import logging
from typing import Dict, List, Tuple
LOG = logging.getLogger("spacr.qt.maturity")
#: app key -> (new stage, why). Ordered by section, the way the sidebar is,
#: so a reader can check a whole section at once.
#:
#: Every reason names the evidence, not an opinion. "Well tested" is not a
#: reason; "769 assertions across four files, a spacr-run module and
#: tutorial lesson 32" is one, because the next person to disagree can go
#: and look.
PROMOTIONS: Dict[str, Tuple[str, str]] = {
"align": ("stable",
"769 assertions across four test files, a `spacr-run align` "
"module with its own pre-flight validation rules, and tutorial "
"lesson 32."),
"convert": ("stable",
"518 assertions across four test files, `convert_folder` "
"behind `spacr-run convert` with a registered defaults "
"entry, and tutorial lesson 35."),
"foreign": ("stable",
"A 3,539-line importer with 750 assertions, pre-flight rules "
"and input-key checks, `spacr-run` aliases and tutorial "
"lesson 36."),
"batch": ("stable",
"The documented headless path for half the other modules is "
"`batch.run_queue`; 279 assertions and six documentation pages "
"rest on it."),
"db_browser": ("stable",
"The largest screen in the shell (2,696 lines) with the "
"largest dedicated test file in the suite — 159 tests, "
"543 assertions — and tutorial lesson 34."),
"external_masks": ("beta",
"A real `spacr-run external_masks` pipeline with a "
"defaults entry and tutorial lesson 31, but only 49 "
"assertions across two small files — the thinnest "
"evidence of any CLI-backed module here."),
"queue": ("beta",
"Fully wired into MainWindow, but the tests cover the 319-line "
"engine rather than the screen, which appears only "
"incidentally in four others."),
"distributed_jobs": ("beta",
"1,018 lines behind a single 8-test file — the "
"weakest tests-per-line ratio in the batch; the "
"`spacr-remote` CLI carries the real contract."),
"illumination": ("beta",
"Wired end to end — CLI module, validation entry, a "
"measure preprocessing hook and registered defaults, "
"148 assertions — but it landed in one commit and has "
"no documentation or lesson at all."),
"data_manager": ("beta",
"222 assertions and a deliberate no-headless-delete "
"design, but no documentation, and a launch crash "
"already in its short history."),
"model_compare": ("stable",
"363 assertions and a live cross-screen signal — Model "
"Zoo's compare button drives it, so shelving it breaks "
"another module."),
"model_zoo": ("stable",
"280 assertions, tutorial lesson 22, an integration in the "
"plugin SDK documentation, its own drop handler, and it "
"feeds Model Compare."),
"plate_view": ("stable",
"165 assertions across three focused files (1536-well and "
"linked-filter among them), `plate_qc.detect_edge_effect` "
"behind it, and tutorial lesson 33."),
"agreement": ("stable",
"130 tests and 431 assertions including a dedicated "
"behavioural regression test, tutorial lesson 23, and a "
"README section."),
"train_compare": ("stable",
"The most-edited screen in this group, 354 assertions "
"across three files including a settings-climb "
"regression test, and tutorial lesson 28."),
"report": ("stable",
"454 assertions and the widest reach of anything here — ten "
"documentation pages and twenty cross-module files refer to "
"it."),
"classifier_evaluation": ("beta",
"The best documentation of the group — its own "
"page, the leakage audit and lesson 39 — but "
"the screen itself has 5 tests and 21 "
"assertions."),
"run_history": ("beta",
"The run_journal layer underneath is settled (226 "
"assertions, ten commits); the screen on top of it is 6 "
"tests old."),
"barcode_qc": ("beta",
"187 assertions, a CLI module, and automatic invocation "
"from the sequencing pipeline — but it landed in a single "
"commit and has no documentation."),
"run_compare": ("beta",
"122 tests and 344 assertions, but one commit old, "
"undocumented, and nothing else in the codebase depends "
"on it yet."),
"layer_viewer": ("beta",
"112 assertions, translations in nine languages and a "
"slot on the Home shelf, but no documentation and a "
"library underneath it that is days old."),
"graph_builder": ("beta",
"48 tests and 146 assertions for a 358-line screen is "
"unusually heavy coverage, but it has no "
"documentation and no dependants."),
"anndata_export": ("beta",
"A real `spacr-run anndata_export` module with "
"registered defaults, a validation entry and 239 "
"assertions, but one commit old and undocumented."),
"feature_dict": ("beta",
"The 3,434-line library under it is load-bearing — "
"report, schema, foreign and anndata_export all import "
"it — with 429 assertions; the panel is newer, and its "
"app row still escapes the registry parity check."),
"invasion": ("stable",
"A real `spacr-run invasion` pipeline with pre-flight "
"rules, curated settings categories, 238 assertions and "
"tutorial lesson 26."),
"power": ("beta",
"197 tests and 465 assertions across five files, and the only "
"module in the Design section — but one commit old with no "
"documentation."),
}
#: app key -> (stage it already has, why that is right). The apps that were
#: reading ``stable`` through the *absence* of an ``APP_STAGE`` line rather
#: than through anybody's decision.
#:
#: These eight are the oldest thing in spaCR: the seven core-pipeline
#: modules the library was written to run, plus Recruitment. Nothing here
#: moves an app; the table exists so that the second phase of :func:`apply`
#: can tell "signed off" apart from "never looked at", which is the whole
#: difference an empty entry could not express. Same evidence rule as
#: :data:`PROMOTIONS`: countable, and checkable by the next reader.
AFFIRMED: Dict[str, Tuple[str, str]] = {
"mask": ("stable",
"The module the library exists for: 20 test files and 1,526 "
"assertions, three `spacr-run` entry points onto "
"`preprocess_generate_masks`, a standalone `spacr.app_mask` "
"window and 17 tutorial files."),
"measure": ("stable",
"19 test files and 796 assertions, `spacr-run measure` over "
"`measure_crop`, a standalone `spacr.app_measure` window, "
"5 tutorial files — and the seg-QC banner and diameter "
"panel were both built onto this screen."),
"annotate": ("stable",
"GUI-only by design and documented as such — annotation is "
"the one step with no headless meaning — with 12 test "
"files, 865 assertions, a standalone `spacr.app_annotate` "
"window and 7 tutorial files."),
"classify_merged": ("stable",
"One screen over both classifiers: 20 test files "
"and 1334 assertions across `spacr.deep_spacr` and "
"`spacr.ml`, five `spacr-run` entry points, and 5 "
"tutorial files. Regression, the hit list and the "
"classifier-evaluation screen all read what it "
"writes."),
"map_barcodes": ("stable",
"8 test files and 530 assertions over "
"`spacr.sequencing`, 2 `spacr-run` entry points, and "
"Barcode QC is invoked automatically from the end of "
"this pipeline."),
"regression": ("stable",
"8 test files and 780 assertions across `spacr.ml` and "
"`spacr.models`, 2 `spacr-run` entry points, and the "
"hit list and Report both consume its output."),
"recruitment": ("stable",
"3 test files and 294 assertions over "
"`spacr.submodules.analyze_recruitment`, 2 `spacr-run` "
"entry points, and a documented parasite-recruitment "
"readout older than the Qt shell itself."),
}
#: Modules assessed and deliberately left where they are. Empty, and that
#: emptiness is the finding: see the module docstring. Kept as a named,
#: iterated-over structure rather than a sentence in a comment so that
#: retiring something later is a one-line change with a reason attached,
#: not a rediscovery of this whole exercise.
RETIREMENTS: Dict[str, str] = {}
#: What an app nobody has assessed reads as. Alpha, because that is the
#: label that means "built and reachable, not yet trusted end to end", and
#: an app whose maturity nobody has stated is not one anybody has trusted.
#:
#: Deliberately NOT the empty string and deliberately not ``stable``: the
#: whole point of the second phase of :func:`apply` is that this default is
#: written down in one place and applied, rather than being whatever falls
#: out of ``APP_STAGE.get(key, STAGE_STABLE)``.
UNASSESSED_STAGE = "alpha"
[docs]
def assessed_keys() -> frozenset:
"""Every app key somebody has actually looked at, in any of the tables."""
return frozenset(PROMOTIONS) | frozenset(AFFIRMED) | frozenset(RETIREMENTS)
def _registered_keys():
"""The app keys currently in the registry, or ``()`` if it is absent.
Imported here rather than at module scope for the same reason
:func:`apply` does it: this module is imported by the launch sequence
and by tests that never build a registry at all.
"""
try:
from .app import APPS
except Exception:
LOG.debug("the app registry is not importable", exc_info=True)
return ()
return tuple(row[0] for row in APPS)
[docs]
def unassessed_apps(stages: Dict[str, str] = None, keys=None) -> List[str]:
"""Registered apps nobody has assessed, in registry order.
An app is unassessed when it appears in none of :data:`PROMOTIONS`,
:data:`AFFIRMED` or :data:`RETIREMENTS`. Whether it *carries* a stage is
a different question: a module that declared alpha for itself is
unassessed and correctly labelled, which is exactly the state most of
the shelf is in.
:param stages: unused for the decision; accepted so callers can pass the
same two arguments they pass :func:`apply`.
:param keys: the app keys to consider; defaults to the live registry.
"""
known = assessed_keys()
return [key for key in (_registered_keys() if keys is None else keys)
if key not in known]
[docs]
def apply(stages: Dict[str, str] = None, keys=None) -> List[str]:
"""Write the assessment into the shell's stage table, then fill the gaps.
Two phases, and they answer two different questions:
1. :data:`PROMOTIONS` — the apps somebody assessed and moved. Idempotent,
and it never *demotes*: a module some other code has already promoted
further than this table says stays where it is. That matters because
the table is a snapshot of one assessment, and the next assessment
should not be silently undone by re-importing this module.
2. Every registered app that is in none of the assessment tables and has
no line of its own is written in as :data:`UNASSESSED_STAGE`. This is
what stops a new module inheriting ``stable`` from the absence of an
entry — see the module docstring. It only ever writes where there is
nothing, so it cannot overrule an author, a plugin, or phase 1.
:param stages: the table to write into. Defaults to
:data:`spacr.qt.app.APP_STAGE`; injectable so a test does not have
to mutate the live registry.
:param keys: the app keys phase 2 considers. Defaults to the live
registry; pass ``()`` to run phase 1 alone.
:returns: the app keys whose stage this call changed.
"""
if stages is None:
try:
from .app import APP_STAGE
except Exception:
LOG.debug("the app registry is not importable", exc_info=True)
return []
stages = APP_STAGE
order = {"alpha": 0, "beta": 1, "stable": 2}
changed: List[str] = []
for app_key, (stage, _reason) in PROMOTIONS.items():
current = str(stages.get(app_key, "stable"))
if order.get(current, 2) >= order.get(stage, 0):
continue
if stage == "stable":
stages.pop(app_key, None)
else:
stages[app_key] = stage
changed.append(app_key)
for app_key in RETIREMENTS:
try:
from .app import unregister_app
unregister_app(app_key)
except Exception:
LOG.debug("could not retire %r", app_key, exc_info=True)
for app_key in unassessed_apps(stages, keys):
if str(stages.get(app_key) or ""):
continue
stages[app_key] = UNASSESSED_STAGE
changed.append(app_key)
return changed
[docs]
def reason_for(app_key: str) -> str:
"""Why ``app_key`` is where it is, or ``""`` when it was not assessed.
An empty string is a real answer here and the UI is entitled to say it
plainly: this module is alpha because nobody has checked it, not because
somebody checked it and concluded alpha.
:param app_key: the app's registry key, e.g. ``"align"``; converted to
``str`` and looked up in :data:`PROMOTIONS`, then :data:`AFFIRMED`,
then :data:`RETIREMENTS`.
"""
entry = PROMOTIONS.get(str(app_key)) or AFFIRMED.get(str(app_key))
if entry is not None:
return entry[1]
return RETIREMENTS.get(str(app_key), "")
[docs]
def register() -> bool:
"""Entry point for :data:`spacr.qt.SELF_REGISTERING_MODULES`.
Idempotent — :func:`apply` never demotes, so a second launch in one
process (the test suite does this) is a no-op rather than a conflict.
"""
apply()
return True