Source code for spacr.qt.setup_screen

"""Manage first-run setup questions and their saved answers.

Every question has a usable default, so setup can be dismissed without
leaving the application unconfigured. The screen is offered once per spaCR
version, allowing new questions to appear after an update while preserving
answers saved for existing settings.
"""
from __future__ import annotations

from typing import Any, Callable, Dict, List, Optional, Tuple

#: Where the version that last answered these questions is remembered.
_KEY_ANSWERED_VERSION = "onboarding/setup_answered_version"


def _settings():
    """Open spaCR's ``QSettings``.

    :returns: the settings store.
    """
    from .preferences import _settings as store

    return store()


[docs] def answered_version() -> str: """The spaCR version that last completed the setup screen, or ``""``.""" return str(_settings().value(_KEY_ANSWERED_VERSION, "") or "")
[docs] def mark_answered(version: str) -> None: """Record that this version's setup has been seen. :param version: the spaCR version whose setup was seen, normally :func:`current_version`; stored as ``str``. """ _settings().setValue(_KEY_ANSWERED_VERSION, str(version))
[docs] def current_version() -> str: """The running spaCR version, or ``"unknown"``.""" try: from .. import __version__ return str(__version__) except Exception: # noqa: BLE001 return "unknown"
[docs] def should_open(version: Optional[str] = None) -> bool: """Should the setup screen open now? :param version: the running version. Defaults to :func:`current_version`. True on a profile that has never answered, and again after an UPDATE. False otherwise, so a user who dismissed it is not asked again until something changes. """ running = str(version or current_version()) return answered_version() != running
#: The flag a launcher passes, and the variable a server sets, to go #: straight to the application. Both spellings, because the flag is typed by #: hand and the variable is set in a job script. SKIP_FLAGS = ("--no-setup", "--skip-setup", "--headless-setup") SKIP_ENV = "SPACR_NO_SETUP"
[docs] def skipped_on_purpose(environ=None) -> bool: """Has this launch asked not to be shown the setup screen? THE SCREEN IS MODAL AND IT IS NOW THE FIRST THING A LAUNCH DRAWS, which is right at a desk and wrong on a server: a batch job that inherits a stale profile would sit on an invisible modal dialog until it was killed, and the only symptom would be a run that never starts. So a launch can say no, and one already has when it runs under the offscreen or minimal platform plugin -- nobody is there to answer a question drawn into a buffer nothing displays. """ import os environ = os.environ if environ is None else environ said = str(environ.get(SKIP_ENV, "")).strip().lower() if said in ("1", "true", "yes", "on"): return True if said in ("0", "false", "no", "off"): return False return str(environ.get("QT_QPA_PLATFORM", "")).strip().lower() in ( "offscreen", "minimal", "vnc")
[docs] def take_the_setup_flags(argv): """(remaining argv, asked to skip). Consumes the flags it recognises. They are consumed rather than ignored because `launch` reads the first argument as the module to open into, and an unconsumed `--no-setup` would be looked up as a module name and quietly open nothing. :param argv: the command-line words after the program name, or ``None``; a word in :data:`SKIP_FLAGS` (compared stripped and lower-cased) is removed and counts as asking to skip. """ kept, asked = [], False for word in list(argv or []): if str(word).strip().lower() in SKIP_FLAGS: asked = True else: kept.append(word) return kept, asked
#: The questions, in the order the request listed them, each as #: ``(key, label, getter, setter, choices)``. #: #: `choices` is ``[(value, label)]`` for a chooser, or ``None`` for a #: toggle. THE ACCESSORS ARE THE PREFERENCE MODULE'S OWN, not a copy: this #: screen writes the same store every other panel reads, so an answer given #: here and an answer given in Preferences are the same answer.
[docs] def questions() -> List[Tuple[str, str, Callable, Callable, Any]]: """Build the question list against the live preference module.""" from . import preferences as prefs def choices_of(names): """Names paired with their readable spellings, for a picker.""" return [(n, str(n).replace("_", " ")) for n in names] out: List[Tuple[str, str, Callable, Callable, Any]] = [ ("language", "Language", prefs.get_language, prefs.set_language, _language_choices()), ("theme", "Theme", prefs.get_theme_choice, prefs.set_theme_choice, [(value, caption) for caption, value in prefs.theme_choices()]), ("colour_blind", "Colour-blind mode", prefs.get_color_blind_mode, prefs.set_color_blind_mode, choices_of(prefs.VALID_CB_MODES)), ("spacr_mode", "Performance", prefs.get_performance_level, prefs.set_performance_level, [(level, prefs.PERFORMANCE_LABELS.get( level, str(level).replace("_", " "))) for level in prefs.PERFORMANCE_LEVELS]), ("hash_inputs", "Reproducibility hash", prefs.get_hash_inputs, prefs.set_hash_inputs, None), ("issue_prompt", "One-click issue filing", prefs.get_issue_prompt_mode, prefs.set_issue_prompt_mode, choices_of(prefs.ISSUE_PROMPT_MODES)), ("ai_default", "AI assistant on at launch", prefs.get_ai_on_by_default, prefs.set_ai_on_by_default, None), ("share_logs", "Include recent logs in a report", prefs.get_share_diagnostic_logs, prefs.set_share_diagnostic_logs, None), ("ai_provider", "AI provider", prefs.get_preferred_provider, prefs.set_preferred_provider, _provider_choices()), ] return [q for q in out if q[4] is None or q[4]]
def _language_choices(): """``[(code, native name)]`` for every language spaCR is translated to. IN THEIR OWN SCRIPT, because the reader of this list is by definition somebody who may not read the current one. """ try: from .i18n import LANGUAGES return [(one.code, one.native_name) for one in LANGUAGES] except Exception: # noqa: BLE001 return [("en", "English")] def _provider_choices(): """The installed providers, or ``[]``. AN EMPTY LIST REMOVES THE QUESTION, which `questions()` does at the bottom. Asking somebody to choose between providers none of which are installed is asking them to answer a question with no true answers -- and this screen's rule is that every question has a working default. """ try: from .ai.providers import list_providers names = [str(getattr(p, "name", "") or "") for p in list_providers()] except Exception: # noqa: BLE001 return [] found = [(n, n.replace("_", " ")) for n in names if n] return [("", "whatever is available")] + found if found else []
[docs] def apply(answers: Dict[str, Any]) -> List[str]: """Write the answers through the preference module. Returns what failed. ONE SETTING'S REFUSAL MUST NOT LOSE THE OTHERS. Each is written on its own, so a value the preference module rejects is reported and the rest are still saved -- a setup screen that discards six good answers because the seventh was bad has cost the user the whole screen. :param answers: ``{question key: value}`` from the setup screen, keyed as in :func:`questions` (e.g. ``"language"``, ``"theme"``); keys that are not questions are ignored, and each value is passed to its question's setter. """ trouble: List[str] = [] for key, _label, _get, setter, _choices in questions(): if key not in answers: continue try: setter(answers[key]) except Exception as exc: # noqa: BLE001 trouble.append(f"{key}: {exc}") return trouble
[docs] def current() -> Dict[str, Any]: """What the answers are now, for the screen to open on.""" out: Dict[str, Any] = {} for key, _label, getter, _set, _choices in questions(): try: out[key] = getter() except Exception: # noqa: BLE001 continue return out