"""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 whenever the
running version differs from the answered one (after an update, for
example). 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 setup screen is modal and is the first thing a launch draws. On a
server, a batch job that inherits a stale profile would wait 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.
Setup asks nothing about AI providers, GitHub or error reports: spaCR
AI (free) and anonymised error reports are on by default, the terms of
use describe both, and Preferences → AI changes them.
"""
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),
]
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")]
[docs]
def apply(answers: Dict[str, Any]) -> List[str]:
"""Write the answers through the preference module. Returns what failed.
Each answer is written on its own, so a value the preference module
rejects is reported and the other answers are still saved.
: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