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