Source code for spacr.qt.maturity

"""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