Source code for spacr.qt.settings_diff

"""
Settings-diff viewer — spot what changed between two runs.

Given two settings dicts (or two run-folder paths, or two CSVs), show
a color-coded diff so users can immediately see which knobs moved
between "the run that worked" and "the run that didn't".

Public API::

    from spacr.qt.settings_diff import (diff_settings, diff_settings_grouped,
                                        SettingsDiffDialog)

    changes = diff_settings(a, b)      # → list of (key, a_val, b_val, kind)
    grouped = diff_settings_grouped(a, b)   # the same, by settings category
    SettingsDiffDialog(a, b, parent).exec()

Diff kinds:

* ``"added"``   — key present in B but not A
* ``"removed"`` — key present in A but not B
* ``"changed"`` — key in both, value differs
* ``"same"``    — a key both runs set to the same value. Never returned by
  :func:`diff_settings`; :func:`diff_settings_grouped` carries it only when
  asked, because the default view of a 200-key settings dict has to be the
  handful that moved.

Grouping uses the same ``spacr.settings.categories`` map both GUIs group
their settings panels by, so "what changed" is read under the same
headings the user set the values under. A key nobody categorised lands in
:data:`UNCATEGORISED` rather than being dropped — an unclassified knob is
still a knob that moved.
"""
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple
from .widgets.sortable_table import install_sorting, table_item


#: Heading for keys that appear in no ``spacr.settings.categories`` bucket.
#: The same word the Qt settings panel puts them under, so the diff and the
#: form that produced it do not disagree about where a key lives.
UNCATEGORISED = "Other"

#: category → keys, resolved once. ``None`` until the first lookup, so
#: importing this module never drags in :mod:`spacr.settings` (about a
#: second of imports) for a caller that only wants :func:`diff_settings`.
_CATEGORY_OF: Optional[Dict[str, str]] = None

#: Category display order, taken from ``spacr.settings.categories``.
_CATEGORY_ORDER: Tuple[str, ...] = ()



@dataclass(frozen=True)
[docs] class DiffRow: """One diff entry. :param key: the settings key. :param a_val: its value in run A, or None when the key is absent there. :param b_val: its value in run B, or None when the key is absent there. :param kind: ``"added"``, ``"removed"``, ``"changed"`` or ``"same"``, as defined in the module description. """ key: str a_val: Any b_val: Any kind: str @property
[docs] def category(self) -> str: """The settings heading this key is grouped under.""" return setting_category(self.key)
@dataclass(frozen=True)
[docs] class CategoryDiff: """Every row from one settings category, differences first. :param category: the heading, e.g. ``"Cellpose"``. :param rows: the rows that differ, sorted by key. :param same: the rows both runs agree on. Empty unless the caller asked for them; :attr:`n_same` counts them either way. :param n_same: how many keys in this category matched, whether or not ``same`` was populated. It is what lets the collapsed view say "Cellpose: 2 changed, 14 unchanged" without carrying 14 rows. """ category: str rows: Tuple[DiffRow, ...] = () same: Tuple[DiffRow, ...] = () n_same: int = 0 @property
[docs] def n_changed(self) -> int: """Rows whose value moved.""" return sum(1 for r in self.rows if r.kind == "changed")
@property
[docs] def n_added(self) -> int: """Keys only the second run set.""" return sum(1 for r in self.rows if r.kind == "added")
@property
[docs] def n_removed(self) -> int: """Keys only the first run set.""" return sum(1 for r in self.rows if r.kind == "removed")
[docs] def __len__(self) -> int: """Number of differing rows.""" return len(self.rows)
@dataclass(frozen=True)
[docs] class SettingsDiff: """The whole settings comparison, grouped by category. :param categories: one :class:`CategoryDiff` per heading that has something to show, in ``spacr.settings.categories`` order. :param include_same: whether unchanged keys were carried. :param n_same: how many keys both runs set to the same value, over the whole comparison. A stored count and not a sum over :attr:`categories`, because the default view omits the categories in which *nothing* differs — and "17 settings matched" is still true, and still worth saying, when none of those 17 has a row. """ categories: Tuple[CategoryDiff, ...] = () include_same: bool = False n_same: int = 0 @property
[docs] def rows(self) -> Tuple[DiffRow, ...]: """Every differing row, category order then key order.""" return tuple(r for c in self.categories for r in c.rows)
@property
[docs] def n_changed(self) -> int: """Keys both runs set, to different values.""" return sum(c.n_changed for c in self.categories)
@property
[docs] def n_added(self) -> int: """Keys only the second run set.""" return sum(c.n_added for c in self.categories)
@property
[docs] def n_removed(self) -> int: """Keys only the first run set.""" return sum(c.n_removed for c in self.categories)
@property
[docs] def identical(self) -> bool: """True when nothing differs at all.""" return not any(c.rows for c in self.categories)
[docs] def category(self, name: str) -> Optional[CategoryDiff]: """Return one category's block, or ``None`` if it has nothing. :param name: category heading to look up, compared exactly with :attr:`CategoryDiff.category`. """ for candidate in self.categories: if candidate.category == name: return candidate return None
[docs] def summary(self) -> str: """One sentence: how much moved, and where.""" if self.identical: return "Settings are identical." parts = [] for label, count in (("changed", self.n_changed), ("added", self.n_added), ("removed", self.n_removed)): if count: parts.append(f"{count} {label}") where = ", ".join(c.category for c in self.categories if c.rows) return f"{'; '.join(parts)} in {where}."
[docs] def __len__(self) -> int: """Number of differing rows.""" return len(self.rows)
[docs] def diff_settings(a: Dict[str, Any], b: Dict[str, Any]) -> List[DiffRow]: """Return the list of keys that differ between ``a`` and ``b``. Sorted alphabetically by key. ``same``-valued keys are omitted. :param a: baseline settings dict. :param b: comparison settings dict. :returns: list of :class:`DiffRow`. """ return [row for row in _all_rows(a, b) if row.kind != "same"]
def _all_rows(a: Dict[str, Any], b: Dict[str, Any]) -> List[DiffRow]: """Every key in either dict as a :class:`DiffRow`, ``"same"`` included.""" a = a or {} b = b or {} keys = sorted(set(a) | set(b)) out: List[DiffRow] = [] for k in keys: av, bv, in_a, in_b = a.get(k), b.get(k), k in a, k in b if in_a and in_b: kind = "same" if _values_equal(av, bv) else "changed" out.append(DiffRow(k, av, bv, kind)) elif in_a: out.append(DiffRow(k, av, None, "removed")) else: out.append(DiffRow(k, None, bv, "added")) return out
[docs] def diff_settings_grouped(a: Dict[str, Any], b: Dict[str, Any], *, include_same: bool = False) -> SettingsDiff: """Diff two settings dicts and group the result by settings category. A spaCR run carries around two hundred keys, so an ungrouped diff of two runs that differ in one Cellpose knob and one plate-map column reads as an undifferentiated list. Grouping under the same headings the settings panel uses makes it answerable at a glance: *the change was in Cellpose*. :param a: baseline settings dict. :param b: comparison settings dict. :param include_same: also carry the keys both runs agree on, for the "show everything" toggle. Off by default — the point of the default view is that an unchanged setting is not in it. :returns: a :class:`SettingsDiff`. Categories with nothing to show are absent; with ``include_same`` that means categories neither run mentions at all. """ rows = _all_rows(a, b) buckets: Dict[str, List[DiffRow]] = {} same: Dict[str, List[DiffRow]] = {} for row in rows: target = same if row.kind == "same" else buckets target.setdefault(row.category, []).append(row) blocks: List[CategoryDiff] = [] for name in _ordered_categories(set(buckets) | set(same)): differing = tuple(buckets.get(name, ())) matching = tuple(same.get(name, ())) if not differing and not (include_same and matching): continue blocks.append(CategoryDiff( category=name, rows=differing, same=matching if include_same else (), n_same=len(matching), )) return SettingsDiff(categories=tuple(blocks), include_same=include_same, n_same=sum(len(v) for v in same.values()))
[docs] def setting_category(key: str) -> str: """Return the settings heading ``key`` is grouped under. :param key: a settings key, e.g. ``"cell_diameter"``. :returns: the category name, or :data:`UNCATEGORISED` when the key is in no bucket (a plugin's key, a run-journal bookkeeping column, or a knob nobody has filed yet). """ return _category_map().get(str(key), UNCATEGORISED)
def _category_map() -> Dict[str, str]: """key → category, built once from ``spacr.settings.categories``. Falls back to an empty map — everything :data:`UNCATEGORISED` — when :mod:`spacr.settings` cannot be imported, so a diff still renders in an environment where the heavy settings module is unavailable. """ global _CATEGORY_OF, _CATEGORY_ORDER if _CATEGORY_OF is None: mapping: Dict[str, str] = {} order: List[str] = [] try: from ..settings import categories as _categories except Exception: _categories = {} for name, keys in dict(_categories).items(): order.append(str(name)) for key in keys or (): mapping.setdefault(str(key), str(name)) _CATEGORY_OF = mapping _CATEGORY_ORDER = tuple(order) return _CATEGORY_OF def _ordered_categories(present: Sequence[str]) -> List[str]: """Sort category names into settings-panel order. Declared categories keep the order ``spacr.settings.categories`` writes them in — the order the user reads them in the settings form. Anything else (a plugin heading, :data:`UNCATEGORISED`) follows alphabetically, with :data:`UNCATEGORISED` pinned last because "Other" is where you look when the answer was not under a real heading. """ _category_map() present = set(present) ordered = [name for name in _CATEGORY_ORDER if name in present] rest = sorted(present - set(ordered) - {UNCATEGORISED}) if UNCATEGORISED in present: rest.append(UNCATEGORISED) return ordered + rest def _values_equal(a: Any, b: Any) -> bool: """Compare two setting values structurally. Delegates to :func:`spacr.run_journal.values_equal` so this dialog and :func:`spacr.run_journal.diff_runs` can never disagree about what counts as a change. That matters in practice: the journal round-trips settings through CSV, so an older run stores ``channels`` as the string ``"[0, 1, 2]"`` while a newer one stores the list ``[0, 1, 2]``. The local normaliser below only handles str->int/float/bool, so it reported that pair as a change and the dialog showed differences that weren't real. Falls back to :func:`_normalize` if run_journal cannot be imported, so this module still works standalone. """ try: from ..run_journal import values_equal except Exception: return _normalize(a) == _normalize(b) return values_equal(a, b) def _normalize(v: Any) -> Any: """Weakly-canonicalise a value so `"1"` and `1` compare equal, etc. Retained as the offline fallback for :func:`_values_equal`. """ if isinstance(v, str): s = v.strip() if s.lower() in ("true", "false"): return s.lower() == "true" try: return int(s) except (ValueError, TypeError): pass try: return float(s) except (ValueError, TypeError): pass return s return v
[docs] class SettingsDiffDialog: """Deferred: real Qt dialog is built on demand so this module can be imported (and diff_settings called) without needing PySide6."""
[docs] def __new__(cls, a, b, parent=None, a_label="A", b_label="B"): """Build and return the settings-diff dialog. Qt is imported inside the call so the module can be used headlessly -- :func:`diff_settings` is the part a report needs, and it must not drag a GUI toolkit in with it. :param a: the settings on the left. :param b: the settings on the right. :param parent: parent widget, or ``None``. :param a_label: caption for the left side. :param b_label: caption for the right side. :returns: the dialog, ready to ``exec``. """ from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QDialog, QDialogButtonBox, QLabel, QTableWidget, QTableWidgetItem, QVBoxLayout, ) dlg = QDialog(parent) dlg.setWindowTitle(f"Settings diff — {a_label} → {b_label}") dlg.setMinimumSize(720, 480) layout = QVBoxLayout(dlg) rows = diff_settings(_load(a), _load(b)) summary = QLabel( f"<b>{len(rows)} differences</b> " f"({sum(1 for r in rows if r.kind=='changed')} changed, " f"{sum(1 for r in rows if r.kind=='added')} added, " f"{sum(1 for r in rows if r.kind=='removed')} removed) " f"between <code>{a_label}</code> and <code>{b_label}</code>." ) summary.setTextFormat(Qt.RichText) layout.addWidget(summary) table = QTableWidget(len(rows), 4, dlg) install_sorting(table) table.setHorizontalHeaderLabels( ["Key", a_label, b_label, "Change"] ) table.verticalHeader().setVisible(False) table.setAlternatingRowColors(True) table.setEditTriggers(QTableWidget.NoEditTriggers) colours = { "added": "#144d1e", "removed": "#4d1414", "changed": "#494914", } for i, r in enumerate(rows): tint = _qcolor(colours[r.kind]) cells = (r.key, _render(r.a_val), _render(r.b_val), r.kind) for col, text in enumerate(cells): item = table_item(text) item.setBackground(tint) table.setItem(i, col, item) table.resizeColumnsToContents() table.setColumnWidth(0, 220) layout.addWidget(table, 1) btns = QDialogButtonBox(QDialogButtonBox.Close) btns.rejected.connect(dlg.reject) layout.addWidget(btns) return dlg
def _render(v: Any) -> str: """Render one settings value for the diff table. :param v: the value. :returns: its ``repr``, so a string is visibly quoted and cannot be confused with the number or the identifier that prints the same; ``None`` renders as a dash. """ if v is None: return "—" return repr(v) def _qcolor(hex_str: str): """Build a ``QColor`` from a hex string. Imported inside the call so this module stays usable headlessly. :param hex_str: the colour. :returns: the colour object. """ from PySide6.QtGui import QColor return QColor(hex_str) def _load(source) -> Dict[str, Any]: """Accept a dict, a Path to a run folder, or a Path to settings.json/csv.""" if isinstance(source, dict): return source p = Path(source) if p.is_dir(): from ..run_journal import load_run_settings return load_run_settings(p) if p.suffix == ".json": import json return json.loads(p.read_text()) if p.suffix == ".csv": import csv out: Dict[str, Any] = {} with open(p) as f: for row in csv.reader(f): if row and row[0] and row[0] != "Key": out[row[0]] = row[1] if len(row) > 1 else "" return out raise ValueError(f"unsupported source for _load: {source!r}")