"""Which published lesson teaches a module, and the URL that opens it.
The lesson library at ``<docs>/tutorials/`` deep-links with ``#lesson=<id>``,
so pointing a reader at the lesson for the module they are hovering is a
lookup and a string. The lookup table is
``spacr/resources/tutorial_index.json``, written by
``tools/build_tutorial_index.py`` from the docs build's own lesson catalog --
see there for why it is not the catalog itself.
No network access and no heavy imports: this is read on the hover path, from Home and
from the dock, so it caches the table on first use and answers ``""`` for
anything it cannot resolve. A module with no lesson gets no link, and no
exception is raised in a hover handler.
The same table carries the whole lesson list, which ``spacr-tutorial`` prints
and resolves a lesson number, slug or screen name against; see
:func:`lessons` and :func:`find_lesson`.
"""
from __future__ import annotations
from typing import Dict, List, Optional, Tuple
#: The published library, and the fragment it reads a lesson id out of.
#: Duplicated from :data:`spacr.qt.app.TUTORIALS_URL` rather than imported:
#: `app` builds the whole main window, and this module is imported from a
#: hover handler.
TUTORIALS_URL = "https://einarolafsson.github.io/spacr/tutorials/"
#: ``{app_key: {"lesson": id, "title": title}}``, or None until first read.
_INDEX: Optional[Dict[str, dict]] = None
#: Every lesson in lesson-number order, or None until first read.
_LESSONS: Optional[List[dict]] = None
def _resource(files, name: str):
"""``name`` in ``spacr/resources``, as ``importlib.resources`` gives it.
``spacr.resources`` is a folder without an ``__init__``, and Python 3.9's
``files`` raises ``TypeError`` on such a namespace package; the folder
next to this package is read instead.
:param files: ``importlib.resources.files``.
:param name: the file name inside ``spacr/resources``.
:returns: a path-like object with ``read_text``.
"""
try:
return files("spacr.resources") / name
except TypeError:
from pathlib import Path
return Path(__file__).resolve().parents[1] / "resources" / name
def _index() -> Dict[str, dict]:
"""The bundled table, read once. ``{}`` when it cannot be read."""
global _INDEX
if _INDEX is None:
try:
import json
from importlib.resources import files
raw = _resource(files, "tutorial_index.json")
data = json.loads(raw.read_text(encoding="utf-8"))
lessons = data.get("lessons") or {}
_INDEX = {str(k): v for k, v in lessons.items()
if isinstance(v, dict)}
except Exception: # noqa: BLE001
_INDEX = {}
return _INDEX
[docs]
def lesson_for(app_key: str) -> str:
"""The lesson id that teaches ``app_key``, or ``""``.
:param app_key: the app key looked up in the bundled
``tutorial_index.json``; None or empty gives no lesson.
"""
entry = _index().get(str(app_key or ""))
return str((entry or {}).get("lesson") or "")
[docs]
def lesson_title(app_key: str) -> str:
"""The lesson's own title, for a tooltip. ``""`` when there is none.
:param app_key: the app key looked up in the bundled
``tutorial_index.json``; None or empty gives no lesson.
"""
entry = _index().get(str(app_key or ""))
return str((entry or {}).get("title") or "")
[docs]
def tutorial_url(app_key: str) -> str:
"""The URL that opens ``app_key``'s lesson, or ``""`` when it has none.
Empty rather than the library's front page on purpose. The link is drawn
only when this answers, so a word that would have dropped the reader at
an index of seventy-three lessons is simply not offered -- the rule the
tooltip footer already follows for **Animation**.
:param app_key: the app key looked up in the bundled
``tutorial_index.json``; None or empty gives no lesson.
"""
lesson = lesson_for(app_key)
return lesson_url(lesson) if lesson else ""
[docs]
def has_tutorial(app_key: str) -> bool:
"""Whether ``app_key`` has a lesson to link to.
:param app_key: the app key looked up in the bundled
``tutorial_index.json``; None or empty gives no lesson.
"""
return bool(lesson_for(app_key))
[docs]
def lesson_url(lesson: str) -> str:
"""The URL that opens one lesson of the library.
:param lesson: the lesson id, such as ``07_mask``.
:returns: the library URL with the ``#lesson=`` fragment it reads.
"""
return f"{TUTORIALS_URL}#lesson={lesson}"
[docs]
def lessons() -> List[dict]:
"""Every lesson in the library, in lesson-number order.
Read once from the bundled table and cached; an unreadable table gives
an empty list, as the module lookups give ``""``.
:returns: one dict per lesson with ``number`` (int), ``lesson`` (its id,
such as ``07_mask``), ``slug``, ``title`` and ``app_key`` (the screen
it covers, ``""`` for a lesson about no single screen).
"""
global _LESSONS
if _LESSONS is None:
try:
import json
from importlib.resources import files
raw = _resource(files, "tutorial_index.json")
rows = json.loads(raw.read_text(encoding="utf-8")).get("catalog")
_LESSONS = sorted(
({"number": int(row.get("number") or 0),
"lesson": str(row["lesson"]),
"slug": str(row.get("slug") or ""),
"title": str(row.get("title") or ""),
"app_key": str(row.get("app_key") or "")}
for row in rows or [] if isinstance(row, dict)
and row.get("lesson")),
key=lambda row: row["number"])
except (ImportError, OSError, ValueError, TypeError, AttributeError):
_LESSONS = []
return list(_LESSONS)
def _spelling(name: object) -> str:
"""Spell ``name`` the way the lesson lookup compares names.
Lower case, ``_`` for each hyphen and space, and a number without its
leading zeros, so ``Make-Masks`` is ``make_masks`` and ``07`` is ``7``.
:param name: a typed name, or a catalog field.
"""
text = str(name).strip().lower().replace("-", "_").replace(" ", "_")
return str(int(text)) if text.isdigit() else text
[docs]
def find_lesson(name: str) -> Tuple[Optional[dict], List[str]]:
"""The lesson ``name`` names, or the names closest to it.
``name`` may be a lesson number (``7`` or ``07``), a lesson id
(``07_mask``), the key of the screen a lesson covers (``measure``) or a
slug (``plaque-assay``). Case, hyphens and spaces do not matter. When two
lessons answer to one name, the earlier kind in that list wins, and among
lessons of one kind the lowest-numbered.
:param name: what the reader typed.
:returns: ``(lesson, [])`` as :func:`lessons` gives it, or ``(None,
suggestions)`` when nothing matches: up to five slugs and screen
names, the closest spellings first, then those containing ``name``.
"""
import difflib
catalog = lessons()
names: Dict[str, dict] = {}
for field in ("number", "lesson", "app_key", "slug"):
for lesson in catalog:
key = _spelling(lesson[field])
if key:
names.setdefault(key, lesson)
wanted = _spelling(name)
if wanted in names:
return names[wanted], []
words = sorted({_spelling(lesson[field]) for lesson in catalog
for field in ("slug", "app_key")} - {""})
close = difflib.get_close_matches(wanted, words, n=5, cutoff=0.6)
within = [word for word in words if wanted and wanted in word]
return None, list(dict.fromkeys(close + within))[:5]