SRE.informations

Numbered sections, cross-references and table of contents of self.informations.

The sections of a course are numbered by hand otherwise (## 7. Routes), with a hand-written table of contents and mentions such as (section 7): inserting a section means renumbering all of that in every language. A lab calls four functions, imported from SRE.lib_sre like tr() and instructor(), which only emit plain-text markers; the numbers, the links and the table of contents are computed when info.json is written (Grade0.save_lab_info(): resolve_informations() on the course, then resolve_text() on the question texts with the numbering of the course), so there is nothing else to call, and sre check validates the markers and prints the numbered outline of the course (outline()).

Usage, in NetScheme.__init__::

from SRE.lib_sre import tr, no_tr, info_subtitle, info_tag, info_ref, info_toc

self.informations = (
    no_tr("## ") + title + no_tr("\n")
    + tr("**Sommaire**\n") + info_toc()
    + info_subtitle(tr("À quoi sert DHCP"), tag="why")
    + tr("""
Pour communiquer ... (voir la section @@ref:lease@@).

### @@sec@@ Transport et adresses

Le client ... @@tag:ports@@ les ports 67 et 68 ...
""")
    + info_subtitle(tr("La vie d'un bail"), tag="lease")
    + tr("""...""")
)

and in Grade.grade()::

description=tr("Configurez le relais (section @@ref:relay@@ des Informations) ...")

The functions return plain strings (a tr() title keeps its languages), to be added with + like any text. The markers they return may also be typed by hand inside a tr() block, which is the natural way for a sub-section or a reference in the middle of a long text:

====================================== ==================================== ========================================== function marker resolved form in the course ====================================== ==================================== ========================================== info_subtitle(title, level=1, tag) ## @@sec@@ Title ## <a id="sec.2"></a>2. Title ### @@sec:tag@@ Title ### <a id="tag"></a>2.1. Title info_tag(tag) @@tag:tag@@ <a id="tag"></a> (number: its section) info_ref(tag) @@ref:tag@@ [2.1](#tag) info_ref(tag, title=True) @@ref:tag:title@@ [2.1. Title](#tag) info_toc(depth=None) @@toc@@ / @@toc:depth@@ nested bullet list of [2.1. Title](#tag) ====================================== ==================================== ==========================================

Rules:

  • the level of a typed header is its number of # minus one (##: level 1); a # header is the lab title and is never numbered; a header without a marker is not numbered either;
  • a tag is made of letters, digits, _ and - (English, like the identifiers of the lab); it must be unique, and a level cannot be skipped (no #### right after a ##);
  • in a question text only @@ref@@ is allowed, and it gives the bare number (2.1): the browser of a question has no anchor to scroll to;
  • the headers are numbered 1., 2.1., 2.1.3.; a reference is the number without the final dot; the table of contents lists every level (info_toc(depth=1) for the first one), as a nested markdown bullet list indented like the line of its marker;
  • a marker inside a fenced code block is left as it is;
  • do not number a header inside instructor(): the numbers must be the same in and out of the instructor mode, so a @@sec@@, @@tag@@ or @@toc@@ standing inside an instructor fragment is dropped (@@ref@@ is resolved there);
  • a text that gets a @@ref@@ through TranslatedText.format() must hold no literal brace; type the marker inside the tr() text instead;
  • the languages of a tr() course must declare the same sections and tags in the same order (sre check reports a translation that missed a header).

Errors are reported as ValueError when the text is resolved (by sre check, or by sre start like any lab bug): a @@sec@@ outside a header line, a # header, a level jump, a tag defined twice, an unknown tag, a numbered header without a title, a @@sec@@ / @@tag@@ / @@toc@@ in a question text, and languages of one TranslatedText whose sections or tags differ. A question hash is computed from the marked title, so a renumbering never changes it.

This module imports nothing from SRE but instructor_text.

  1"""Numbered sections, cross-references and table of contents of ``self.informations``.
  2
  3The sections of a course are numbered by hand otherwise (``## 7. Routes``), with a hand-written
  4table of contents and mentions such as ``(section 7)``: inserting a section means renumbering
  5all of that in every language.  A lab calls four functions, imported from ``SRE.lib_sre`` like
  6``tr()`` and ``instructor()``, which only emit plain-text *markers*; the numbers, the links and
  7the table of contents are computed when ``info.json`` is written (``Grade0.save_lab_info()``:
  8:func:`resolve_informations` on the course, then :func:`resolve_text` on the question texts with
  9the numbering of the course), so there is nothing else to call, and ``sre check`` validates the
 10markers and prints the numbered outline of the course (:func:`outline`).
 11
 12Usage, in ``NetScheme.__init__``::
 13
 14    from SRE.lib_sre import tr, no_tr, info_subtitle, info_tag, info_ref, info_toc
 15
 16    self.informations = (
 17        no_tr("## ") + title + no_tr("\\n")
 18        + tr("**Sommaire**\\n") + info_toc()
 19        + info_subtitle(tr("À quoi sert DHCP"), tag="why")
 20        + tr(\"\"\"
 21    Pour communiquer ... (voir la section @@ref:lease@@).
 22
 23    ### @@sec@@ Transport et adresses
 24
 25    Le client ... @@tag:ports@@ les ports 67 et 68 ...
 26    \"\"\")
 27        + info_subtitle(tr("La vie d'un bail"), tag="lease")
 28        + tr(\"\"\"...\"\"\")
 29    )
 30
 31and in ``Grade.grade()``::
 32
 33    description=tr("Configurez le relais (section @@ref:relay@@ des Informations) ...")
 34
 35The functions return plain strings (a ``tr()`` title keeps its languages), to be added with ``+``
 36like any text.  The markers they return may also be typed by hand inside a ``tr()`` block, which
 37is the natural way for a sub-section or a reference in the middle of a long text:
 38
 39======================================  ====================================  ==========================================
 40function                                marker                                resolved form in the course
 41======================================  ====================================  ==========================================
 42``info_subtitle(title, level=1, tag)``  ``## @@sec@@ Title``                  ``## <a id="sec.2"></a>2. Title``
 43                                        ``### @@sec:tag@@ Title``             ``### <a id="tag"></a>2.1. Title``
 44``info_tag(tag)``                       ``@@tag:tag@@``                       ``<a id="tag"></a>`` (number: its section)
 45``info_ref(tag)``                       ``@@ref:tag@@``                       ``[2.1](#tag)``
 46``info_ref(tag, title=True)``           ``@@ref:tag:title@@``                 ``[2.1. Title](#tag)``
 47``info_toc(depth=None)``                ``@@toc@@`` / ``@@toc:depth@@``       nested bullet list of ``[2.1. Title](#tag)``
 48======================================  ====================================  ==========================================
 49
 50Rules:
 51
 52- the level of a typed header is its number of ``#`` minus one (``##``: level 1); a ``#`` header
 53  is the lab title and is never numbered; a header without a marker is not numbered either;
 54- a tag is made of letters, digits, ``_`` and ``-`` (English, like the identifiers of the lab);
 55  it must be unique, and a level cannot be skipped (no ``####`` right after a ``##``);
 56- in a question text only ``@@ref@@`` is allowed, and it gives the bare number (``2.1``): the
 57  browser of a question has no anchor to scroll to;
 58- the headers are numbered ``1.``, ``2.1.``, ``2.1.3.``; a reference is the number without the
 59  final dot; the table of contents lists every level (``info_toc(depth=1)`` for the first one),
 60  as a nested markdown bullet list indented like the line of its marker;
 61- a marker inside a fenced code block is left as it is;
 62- do not number a header inside ``instructor()``: the numbers must be the same in and out of the
 63  instructor mode, so a ``@@sec@@``, ``@@tag@@`` or ``@@toc@@`` standing inside an instructor
 64  fragment is dropped (``@@ref@@`` is resolved there);
 65- a text that gets a ``@@ref@@`` through ``TranslatedText.format()`` must hold no literal brace;
 66  type the marker inside the ``tr()`` text instead;
 67- the languages of a ``tr()`` course must declare the same sections and tags in the same order
 68  (``sre check`` reports a translation that missed a header).
 69
 70Errors are reported as :class:`ValueError` when the text is resolved (by ``sre check``, or by
 71``sre start`` like any lab bug): a ``@@sec@@`` outside a header line, a ``#`` header, a level
 72jump, a tag defined twice, an unknown tag, a numbered header without a title, a ``@@sec@@`` /
 73``@@tag@@`` / ``@@toc@@`` in a question text, and languages of one ``TranslatedText`` whose
 74sections or tags differ.  A question hash is computed from the marked title, so a renumbering
 75never changes it.
 76
 77This module imports nothing from SRE but ``instructor_text``.
 78"""
 79import re
 80
 81from .instructor_text import fragment_spans
 82
 83TAG_RE = re.compile(r'[A-Za-z0-9_-]+')
 84MARKER_RE = re.compile(r'@@(?P<kind>sec|tag|ref|toc)(?::(?P<args>[^@\n]*))?@@')
 85# what stands before a @@sec@@ marker on its line: the hashes of a markdown header
 86_HEADER_RE = re.compile(r'[ \t]*(?P<hashes>#{1,6})[ \t]*')
 87_FENCE_RE = re.compile(r'^[ \t]*(?P<fence>`{3,}|~{3,})', re.MULTILINE)
 88AUTO_ANCHOR_PREFIX = 'sec.'  # id of an untagged section: 'sec.2.1' ('.' never occurs in a tag)
 89
 90
 91def check_tag(tag: str) -> str:
 92    """*tag* when it is a valid tag name, else :class:`ValueError`."""
 93    if not isinstance(tag, str) or not TAG_RE.fullmatch(tag):
 94        raise ValueError(f"invalid tag {tag!r}: letters, digits, '_' and '-' only")
 95    return tag
 96
 97
 98class Target:
 99    """What a tag refers to: a section (*level* set) or a plain ``@@tag@@`` anchor (*level* None,
100    *number* and *title* those of the section it stands in)."""
101
102    __slots__ = ('number', 'title', 'anchor', 'level', 'tag')
103
104    def __init__(self, number: str, title: str, anchor: str, level, tag):
105        self.number = number
106        self.title = title
107        self.anchor = anchor
108        self.level = level
109        self.tag = tag
110
111    @property
112    def label(self) -> str:
113        """``2.1. Title``: the entry of the table of contents, the reference with a title."""
114        return f"{self.number}. {self.title}"
115
116    def __repr__(self):
117        return f"Target({self.number!r}, {self.title!r}, anchor={self.anchor!r}, level={self.level}, tag={self.tag!r})"
118
119
120class Numbering:
121    """The sections and the tags of a course, per language; the first language added is the
122    default one, used for a language the course does not have and for plain strings."""
123
124    def __init__(self):
125        self._languages = {}  # lang -> (sections, targets)
126
127    def add(self, lang, sections: list, targets: dict) -> None:
128        self._languages[lang] = (sections, targets)
129
130    @property
131    def languages(self) -> list:
132        return list(self._languages)
133
134    def _entry(self, lang):
135        if lang in self._languages:
136            return self._languages[lang]
137        if self._languages:
138            return next(iter(self._languages.values()))
139        return [], {}
140
141    def sections(self, lang=None) -> list:
142        """The numbered sections, in text order (:class:`Target` objects)."""
143        return self._entry(lang)[0]
144
145    def targets(self, lang=None) -> dict:
146        """``tag -> Target`` of every tagged section and anchor."""
147        return self._entry(lang)[1]
148
149    def check_consistent(self) -> None:
150        """Every language must declare the same sections (levels and tags) and anchors, in the
151        same order, else :class:`ValueError` (a translation missed a header)."""
152        reference = None
153        for lang, (sections, targets) in self._languages.items():
154            signature = _signature(sections, targets)
155            if reference is None:
156                reference = (lang, signature)
157            elif signature != reference[1]:
158                raise ValueError(
159                    f"the numbered sections and tags of the languages {reference[0]!r} and {lang!r} "
160                    f"differ: {_describe(reference[1])} / {_describe(signature)}")
161
162
163def _signature(sections, targets):
164    items = [('sec', s.level, s.tag) for s in sections]
165    items += [('tag', None, t.tag) for t in targets.values() if t.level is None]
166    return tuple(items)
167
168
169def _describe(signature) -> str:
170    return ', '.join(f"{kind}{':' + tag if tag else ''}{'(' + str(level) + ')' if level else ''}"
171                     for kind, level, tag in signature) or 'none'
172
173
174class _Marker:
175    __slots__ = ('kind', 'args', 'start', 'end', 'text', 'in_fragment', 'replacement', 'depth', 'indent', 'alone')
176
177    def __init__(self, match, in_fragment: bool):
178        self.kind = match.group('kind')
179        self.args = match.group('args')
180        self.start, self.end = match.span()
181        self.text = match.group()
182        self.in_fragment = in_fragment
183        self.replacement = None
184        self.depth = None
185        self.indent = ''
186        self.alone = False  # nothing else on its line
187
188
189def _code_spans(text: str) -> list:
190    """``(start, end)`` of the fenced code blocks of *text* (an unclosed fence runs to the end)."""
191    spans = []
192    opened = None  # (fence string, start offset)
193    for match in _FENCE_RE.finditer(text):
194        fence = match.group('fence')
195        if opened is None:
196            opened = (fence, match.start())
197        elif fence[0] == opened[0][0] and len(fence) >= len(opened[0]):
198            spans.append((opened[1], match.end()))
199            opened = None
200    if opened is not None:
201        spans.append((opened[1], len(text)))
202    return spans
203
204
205def _inside(position: int, spans: list) -> bool:
206    return any(start <= position < end for start, end in spans)
207
208
209def _markers(text: str) -> list:
210    """The markers of *text* outside its fenced code blocks, in text order."""
211    code = _code_spans(text)
212    fragments = fragment_spans(text)
213    return [_Marker(match, _inside(match.start(), fragments))
214            for match in MARKER_RE.finditer(text) if not _inside(match.start(), code)]
215
216
217def _line_bounds(text: str, position: int):
218    start = text.rfind('\n', 0, position) + 1
219    end = text.find('\n', position)
220    return start, len(text) if end < 0 else end
221
222
223def _skip_blanks(text: str, position: int) -> int:
224    while position < len(text) and text[position] in ' \t':
225        position += 1
226    return position
227
228
229def _parse_ref(marker: _Marker):
230    """``(tag, with_title)`` of a @@ref@@ marker."""
231    args = marker.args or ''
232    tag, _, option = args.partition(':')
233    if option not in ('', 'title'):
234        raise ValueError(f"invalid marker {marker.text}: expected @@ref:tag@@ or @@ref:tag:title@@")
235    return check_tag(tag), option == 'title'
236
237
238def _number_course(text: str):
239    """First pass over a course: its markers with the replacement of every @@sec@@ / @@tag@@ (the
240    numbering), the sections in order and the ``tag -> Target`` map."""
241    markers = _markers(text)
242    sections = []
243    targets = {}
244    counters = []
245    current = None  # the section an anchor stands in
246
247    def register(tag, target):
248        if tag in targets:
249            raise ValueError(f"tag {tag!r} is defined twice")
250        targets[tag] = target
251
252    for marker in markers:
253        line_start, line_end = _line_bounds(text, marker.start)
254        marker.indent = text[line_start:_skip_blanks(text, line_start)]
255        marker.alone = not (text[line_start:marker.start].strip() or text[marker.end:line_end].strip())
256        if marker.kind == 'sec':
257            after = _skip_blanks(text, marker.end)
258            if marker.in_fragment:
259                marker.start, marker.end, marker.replacement = marker.start, after, ''
260                continue
261            header = _HEADER_RE.fullmatch(text, line_start, marker.start)
262            if header is None:
263                raise ValueError(f"{marker.text} must open the text of a markdown header "
264                                 f"('## {marker.text} Title'): {text[line_start:line_end].strip()!r}")
265            level = len(header.group('hashes')) - 1
266            title = text[after:line_end].strip()
267            if level == 0:
268                raise ValueError(f"a '#' header is the lab title and cannot be numbered: use '##' "
269                                 f"for level 1 ({title!r})")
270            if not title:
271                raise ValueError(f"numbered header without a title: {text[line_start:line_end].strip()!r}")
272            if level > len(counters) + 1:
273                raise ValueError(f"section {title!r} is at level {level} but no level {level - 1} "
274                                 f"section opened before it")
275            counters = counters[:level - 1] + [(counters[level - 1] + 1) if level <= len(counters) else 1]
276            number = '.'.join(str(n) for n in counters)
277            tag = check_tag(marker.args) if marker.args is not None else None
278            current = Target(number, title, tag or f"{AUTO_ANCHOR_PREFIX}{number}", level, tag)
279            sections.append(current)
280            if tag:
281                register(tag, current)
282            marker.end = after
283            marker.replacement = f'<a id="{current.anchor}"></a>{number}. '
284        elif marker.kind == 'tag':
285            if marker.args is None:
286                raise ValueError(f"invalid marker {marker.text}: expected @@tag:name@@")
287            tag = check_tag(marker.args)
288            if marker.in_fragment:
289                marker.replacement = ''
290                continue
291            register(tag, Target(current.number if current else '', current.title if current else '',
292                                 tag, None, tag))
293            marker.replacement = f'<a id="{tag}"></a>'
294        elif marker.kind == 'toc':
295            if marker.args is not None and not marker.args.isdigit():
296                raise ValueError(f"invalid marker {marker.text}: expected @@toc@@ or @@toc:depth@@")
297            if marker.in_fragment:
298                marker.replacement = ''
299                continue
300            marker.depth = int(marker.args) if marker.args is not None else None
301            if marker.alone:
302                marker.start = line_start  # the list lines carry the indentation themselves
303        else:
304            _parse_ref(marker)  # validated now, resolved by the second pass
305    return markers, sections, targets
306
307
308def _reference(marker: _Marker, targets: dict, links: bool) -> str:
309    tag, with_title = _parse_ref(marker)
310    target = targets.get(tag)
311    if target is None:
312        raise ValueError(f"unknown tag {tag!r} in {marker.text}")
313    if not target.number:
314        raise ValueError(f"{marker.text}: the anchor {tag!r} stands before the first numbered section")
315    label = target.label if with_title else target.number
316    return f"[{label}](#{target.anchor})" if links else label
317
318
319def _toc(sections: list, depth, indent: str, alone: bool) -> str:
320    """The nested bullet list of the sections of the first *depth* levels, every line indented
321    like the line of the marker; a marker standing inside a paragraph gets a block of its own."""
322    lines = [f"{indent}{'    ' * (s.level - 1)}- [{s.label}](#{s.anchor})"
323             for s in sections if depth is None or s.level <= depth]
324    if not lines:
325        return ''
326    body = '\n'.join(lines)
327    return body if alone else f"\n\n{body}\n\n"
328
329
330def _substitute(text: str, markers: list) -> str:
331    pieces = []
332    position = 0
333    for marker in markers:
334        pieces.append(text[position:marker.start])
335        pieces.append(marker.replacement)
336        position = marker.end
337    pieces.append(text[position:])
338    return ''.join(pieces)
339
340
341def _resolve_course(text: str):
342    markers, sections, targets = _number_course(text)
343    for marker in markers:
344        if marker.kind == 'ref':
345            marker.replacement = _reference(marker, targets, links=True)
346        elif marker.kind == 'toc' and marker.replacement is None:
347            marker.replacement = _toc(sections, marker.depth, marker.indent, marker.alone)
348    return _substitute(text, markers), sections, targets
349
350
351def _resolve_refs(text: str, targets: dict, links: bool) -> str:
352    markers = _markers(text)
353    for marker in markers:
354        if marker.kind != 'ref':
355            raise ValueError(f"{marker.text} is only allowed in the informations text "
356                             f"(a question text may hold @@ref@@ only)")
357        marker.replacement = _reference(marker, targets, links)
358    return _substitute(text, markers)
359
360
361def resolve_informations(text):
362    """``(resolved text, numbering)`` of a course: *text* is a ``str`` or a ``TranslatedText``
363    (resolved language by language, which must agree on the sections)."""
364    numbering = Numbering()
365    if isinstance(text, dict):
366        result = {}
367        for lang, value in text.items():
368            result[lang], sections, targets = _resolve_course(value)
369            numbering.add(lang, sections, targets)
370        numbering.check_consistent()
371        return type(text)(result), numbering
372    resolved, sections, targets = _resolve_course(text)
373    numbering.add('', sections, targets)
374    return resolved, numbering
375
376
377def resolve_text(text, numbering: Numbering, links: bool = False):
378    """*text* (``str`` or ``TranslatedText``: a question title or description, a state
379    description) with its ``@@ref@@`` markers resolved against *numbering*; a reference is a link
380    only with *links* (the browser showing the text must hold the anchors of the course)."""
381    if isinstance(text, dict):
382        return type(text)({lang: _resolve_refs(value, numbering.targets(lang), links)
383                           for lang, value in text.items()})
384    return _resolve_refs(text, numbering.targets(), links)
385
386
387def has_markers(text) -> bool:
388    """True when *text* (``str`` or dict of strings) holds a marker outside its code blocks."""
389    if isinstance(text, dict):
390        return any(has_markers(value) for value in text.values())
391    return isinstance(text, str) and bool(_markers(text))
392
393
394def outline(numbering: Numbering, lang=None) -> list:
395    """One line per numbered section, indented by level, the tag between brackets: what
396    ``sre check`` prints."""
397    return [f"{'    ' * (s.level - 1)}{s.number}. {s.title}{f'  [{s.tag}]' if s.tag else ''}"
398            for s in numbering.sections(lang)]
399
400
401# ── the functions a lab calls (re-exported by SRE.lib_sre) ─────────────────────────────────
402
403_MAX_LEVEL = 5  # '######' is the deepest markdown header
404
405
406def info_subtitle(title, level: int = 1, tag: str = ""):
407    """A numbered header of *level* (1: ``##``, 2: ``###``...) with *title* (a ``str`` or a
408    ``tr()`` text), standing in a paragraph of its own; *tag* names the section for
409    :func:`info_ref`."""
410    if not isinstance(level, int) or isinstance(level, bool) or not 1 <= level <= _MAX_LEVEL:
411        raise ValueError(f"info_subtitle(): level must be an integer from 1 to {_MAX_LEVEL}, not {level!r}")
412    marker = f"@@sec:{check_tag(tag)}@@" if tag else "@@sec@@"
413    return "\n\n" + "#" * (level + 1) + " " + marker + " " + title + "\n\n"
414
415
416def info_tag(tag: str) -> str:
417    """An anchor at this spot, named *tag*: a reference to it gives the number of the section it
418    stands in and scrolls the course to the spot."""
419    return f"@@tag:{check_tag(tag)}@@"
420
421
422def info_ref(tag: str, title: bool = False) -> str:
423    """The number of the section tagged *tag* (``2.1``), followed by its title with *title*
424    (``2.1. Title``); a link to the section in the course."""
425    return f"@@ref:{check_tag(tag)}:title@@" if title else f"@@ref:{check_tag(tag)}@@"
426
427
428def info_toc(depth=None) -> str:
429    """The table of contents of the course, in a paragraph of its own: every numbered section, or
430    the sections of the first *depth* levels."""
431    if depth is None:
432        return "\n\n@@toc@@\n\n"
433    if not isinstance(depth, int) or isinstance(depth, bool) or depth < 1:
434        raise ValueError(f"info_toc(): depth must be a positive integer, not {depth!r}")
435    return f"\n\n@@toc:{depth}@@\n\n"
TAG_RE = re.compile('[A-Za-z0-9_-]+')
MARKER_RE = re.compile('@@(?P<kind>sec|tag|ref|toc)(?::(?P<args>[^@\\n]*))?@@')
AUTO_ANCHOR_PREFIX = 'sec.'
def check_tag(tag: str) -> str:
92def check_tag(tag: str) -> str:
93    """*tag* when it is a valid tag name, else :class:`ValueError`."""
94    if not isinstance(tag, str) or not TAG_RE.fullmatch(tag):
95        raise ValueError(f"invalid tag {tag!r}: letters, digits, '_' and '-' only")
96    return tag

tag when it is a valid tag name, else ValueError.

class Target:
 99class Target:
100    """What a tag refers to: a section (*level* set) or a plain ``@@tag@@`` anchor (*level* None,
101    *number* and *title* those of the section it stands in)."""
102
103    __slots__ = ('number', 'title', 'anchor', 'level', 'tag')
104
105    def __init__(self, number: str, title: str, anchor: str, level, tag):
106        self.number = number
107        self.title = title
108        self.anchor = anchor
109        self.level = level
110        self.tag = tag
111
112    @property
113    def label(self) -> str:
114        """``2.1. Title``: the entry of the table of contents, the reference with a title."""
115        return f"{self.number}. {self.title}"
116
117    def __repr__(self):
118        return f"Target({self.number!r}, {self.title!r}, anchor={self.anchor!r}, level={self.level}, tag={self.tag!r})"

What a tag refers to: a section (level set) or a plain @@tag@@ anchor (level None, number and title those of the section it stands in).

Target(number: str, title: str, anchor: str, level, tag)
105    def __init__(self, number: str, title: str, anchor: str, level, tag):
106        self.number = number
107        self.title = title
108        self.anchor = anchor
109        self.level = level
110        self.tag = tag
number
title
anchor
level
tag
label: str
112    @property
113    def label(self) -> str:
114        """``2.1. Title``: the entry of the table of contents, the reference with a title."""
115        return f"{self.number}. {self.title}"

2.1. Title: the entry of the table of contents, the reference with a title.

class Numbering:
121class Numbering:
122    """The sections and the tags of a course, per language; the first language added is the
123    default one, used for a language the course does not have and for plain strings."""
124
125    def __init__(self):
126        self._languages = {}  # lang -> (sections, targets)
127
128    def add(self, lang, sections: list, targets: dict) -> None:
129        self._languages[lang] = (sections, targets)
130
131    @property
132    def languages(self) -> list:
133        return list(self._languages)
134
135    def _entry(self, lang):
136        if lang in self._languages:
137            return self._languages[lang]
138        if self._languages:
139            return next(iter(self._languages.values()))
140        return [], {}
141
142    def sections(self, lang=None) -> list:
143        """The numbered sections, in text order (:class:`Target` objects)."""
144        return self._entry(lang)[0]
145
146    def targets(self, lang=None) -> dict:
147        """``tag -> Target`` of every tagged section and anchor."""
148        return self._entry(lang)[1]
149
150    def check_consistent(self) -> None:
151        """Every language must declare the same sections (levels and tags) and anchors, in the
152        same order, else :class:`ValueError` (a translation missed a header)."""
153        reference = None
154        for lang, (sections, targets) in self._languages.items():
155            signature = _signature(sections, targets)
156            if reference is None:
157                reference = (lang, signature)
158            elif signature != reference[1]:
159                raise ValueError(
160                    f"the numbered sections and tags of the languages {reference[0]!r} and {lang!r} "
161                    f"differ: {_describe(reference[1])} / {_describe(signature)}")

The sections and the tags of a course, per language; the first language added is the default one, used for a language the course does not have and for plain strings.

def add(self, lang, sections: list, targets: dict) -> None:
128    def add(self, lang, sections: list, targets: dict) -> None:
129        self._languages[lang] = (sections, targets)
languages: list
131    @property
132    def languages(self) -> list:
133        return list(self._languages)
def sections(self, lang=None) -> list:
142    def sections(self, lang=None) -> list:
143        """The numbered sections, in text order (:class:`Target` objects)."""
144        return self._entry(lang)[0]

The numbered sections, in text order (Target objects).

def targets(self, lang=None) -> dict:
146    def targets(self, lang=None) -> dict:
147        """``tag -> Target`` of every tagged section and anchor."""
148        return self._entry(lang)[1]

tag -> Target of every tagged section and anchor.

def check_consistent(self) -> None:
150    def check_consistent(self) -> None:
151        """Every language must declare the same sections (levels and tags) and anchors, in the
152        same order, else :class:`ValueError` (a translation missed a header)."""
153        reference = None
154        for lang, (sections, targets) in self._languages.items():
155            signature = _signature(sections, targets)
156            if reference is None:
157                reference = (lang, signature)
158            elif signature != reference[1]:
159                raise ValueError(
160                    f"the numbered sections and tags of the languages {reference[0]!r} and {lang!r} "
161                    f"differ: {_describe(reference[1])} / {_describe(signature)}")

Every language must declare the same sections (levels and tags) and anchors, in the same order, else ValueError (a translation missed a header).

def resolve_informations(text):
362def resolve_informations(text):
363    """``(resolved text, numbering)`` of a course: *text* is a ``str`` or a ``TranslatedText``
364    (resolved language by language, which must agree on the sections)."""
365    numbering = Numbering()
366    if isinstance(text, dict):
367        result = {}
368        for lang, value in text.items():
369            result[lang], sections, targets = _resolve_course(value)
370            numbering.add(lang, sections, targets)
371        numbering.check_consistent()
372        return type(text)(result), numbering
373    resolved, sections, targets = _resolve_course(text)
374    numbering.add('', sections, targets)
375    return resolved, numbering

(resolved text, numbering) of a course: text is a str or a TranslatedText (resolved language by language, which must agree on the sections).

def resolve_text(text, numbering: Numbering, links: bool = False):
378def resolve_text(text, numbering: Numbering, links: bool = False):
379    """*text* (``str`` or ``TranslatedText``: a question title or description, a state
380    description) with its ``@@ref@@`` markers resolved against *numbering*; a reference is a link
381    only with *links* (the browser showing the text must hold the anchors of the course)."""
382    if isinstance(text, dict):
383        return type(text)({lang: _resolve_refs(value, numbering.targets(lang), links)
384                           for lang, value in text.items()})
385    return _resolve_refs(text, numbering.targets(), links)

text (str or TranslatedText: a question title or description, a state description) with its @@ref@@ markers resolved against numbering; a reference is a link only with links (the browser showing the text must hold the anchors of the course).

def has_markers(text) -> bool:
388def has_markers(text) -> bool:
389    """True when *text* (``str`` or dict of strings) holds a marker outside its code blocks."""
390    if isinstance(text, dict):
391        return any(has_markers(value) for value in text.values())
392    return isinstance(text, str) and bool(_markers(text))

True when text (str or dict of strings) holds a marker outside its code blocks.

def outline(numbering: Numbering, lang=None) -> list:
395def outline(numbering: Numbering, lang=None) -> list:
396    """One line per numbered section, indented by level, the tag between brackets: what
397    ``sre check`` prints."""
398    return [f"{'    ' * (s.level - 1)}{s.number}. {s.title}{f'  [{s.tag}]' if s.tag else ''}"
399            for s in numbering.sections(lang)]

One line per numbered section, indented by level, the tag between brackets: what sre check prints.

def info_subtitle(title, level: int = 1, tag: str = ''):
407def info_subtitle(title, level: int = 1, tag: str = ""):
408    """A numbered header of *level* (1: ``##``, 2: ``###``...) with *title* (a ``str`` or a
409    ``tr()`` text), standing in a paragraph of its own; *tag* names the section for
410    :func:`info_ref`."""
411    if not isinstance(level, int) or isinstance(level, bool) or not 1 <= level <= _MAX_LEVEL:
412        raise ValueError(f"info_subtitle(): level must be an integer from 1 to {_MAX_LEVEL}, not {level!r}")
413    marker = f"@@sec:{check_tag(tag)}@@" if tag else "@@sec@@"
414    return "\n\n" + "#" * (level + 1) + " " + marker + " " + title + "\n\n"

A numbered header of level (1: ##, 2: ###...) with title (a str or a tr() text), standing in a paragraph of its own; tag names the section for info_ref().

def info_tag(tag: str) -> str:
417def info_tag(tag: str) -> str:
418    """An anchor at this spot, named *tag*: a reference to it gives the number of the section it
419    stands in and scrolls the course to the spot."""
420    return f"@@tag:{check_tag(tag)}@@"

An anchor at this spot, named tag: a reference to it gives the number of the section it stands in and scrolls the course to the spot.

def info_ref(tag: str, title: bool = False) -> str:
423def info_ref(tag: str, title: bool = False) -> str:
424    """The number of the section tagged *tag* (``2.1``), followed by its title with *title*
425    (``2.1. Title``); a link to the section in the course."""
426    return f"@@ref:{check_tag(tag)}:title@@" if title else f"@@ref:{check_tag(tag)}@@"

The number of the section tagged tag (2.1), followed by its title with title (2.1. Title); a link to the section in the course.

def info_toc(depth=None) -> str:
429def info_toc(depth=None) -> str:
430    """The table of contents of the course, in a paragraph of its own: every numbered section, or
431    the sections of the first *depth* levels."""
432    if depth is None:
433        return "\n\n@@toc@@\n\n"
434    if not isinstance(depth, int) or isinstance(depth, bool) or depth < 1:
435        raise ValueError(f"info_toc(): depth must be a positive integer, not {depth!r}")
436    return f"\n\n@@toc:{depth}@@\n\n"

The table of contents of the course, in a paragraph of its own: every numbered section, or the sections of the first depth levels.