SRE.common

  1import hashlib
  2import json
  3import re
  4import unicodedata
  5from dataclasses import dataclass, asdict, field
  6from typing import List
  7
  8import msgpack
  9from enum import Enum
 10
 11from . import access, params
 12from .instructor_text import strip_instructor, unwrap_instructor
 13
 14# graphviz node sizes (inches) of the icons of the schema (GUI and export PDF)
 15SWITCH_ICON_SIZE = ("0.5", "0.5")
 16NETWORK_ICON_SIZE = ("1.0", "0.6")      # a network drawn as an icon of params.network_shape_icons (a cloud)
 17
 18
 19def icon_node_attrs(image: str, color: str | None, width: str, height: str) -> dict:
 20    """Graphviz attributes of a network drawn as an icon with its label below: a box filled with
 21    its colour behind the image when it has one, nothing otherwise."""
 22    attrs = {"image": image, "labelloc": "b", "width": width, "height": height,
 23             "fixedsize": "true", "imagescale": "true", "fontsize": "16"}
 24    if color:
 25        attrs.update(shape="box", style="filled", fillcolor=color)
 26    else:
 27        attrs["shape"] = "none"
 28    return attrs
 29
 30
 31class TranslatedText(dict):
 32    """A dict subclass holding translations keyed by language code (e.g. 'en', 'fr').
 33
 34    Use ``resolve(lang)`` to get the text for a specific language, falling back to the
 35    first available translation.  ``from_value`` ensures backward-compat with plain strings.
 36    """
 37
 38    def resolve(self, lang: str, fallback: str = '') -> str:
 39        return self.get(lang) or next(iter(self.values()), fallback)
 40
 41    def resolve_priority(self, langs: list, fallback: str = '') -> str:
 42        """Return the first translation found in the priority list, or first available."""
 43        for lang in langs:
 44            if self.get(lang):
 45                return self[lang]
 46        return next(iter(self.values()), fallback)
 47
 48    def __add__(self, other) -> 'TranslatedText':
 49        if isinstance(other, str):
 50            return TranslatedText({lang: self[lang] + other for lang in self})
 51        if not isinstance(other, TranslatedText):
 52            return NotImplemented
 53        if not self:
 54            return TranslatedText(other)
 55        if not other:
 56            return TranslatedText(self)
 57        # Merge mismatched language sets gracefully (partial translations): take
 58        # the union of languages, falling back to each side's default-language
 59        # (first-key) value for a language that side lacks.
 60        self_default = next(iter(self.values()))
 61        other_default = next(iter(other.values()))
 62        langs = list(dict.fromkeys((*self, *other)))
 63        return TranslatedText({
 64            lang: self.get(lang, self_default) + other.get(lang, other_default)
 65            for lang in langs
 66        })
 67
 68    def __radd__(self, other) -> 'TranslatedText':
 69        if isinstance(other, str):
 70            return TranslatedText({lang: other + self[lang] for lang in self})
 71        return NotImplemented
 72
 73    def format(self, **kwargs) -> 'TranslatedText':
 74        """Apply str.format(**kwargs) to every language value."""
 75        return TranslatedText({lang: text.format(**kwargs) for lang, text in self.items()})
 76
 77    @classmethod
 78    def from_value(cls, v, default_lang: str = 'en') -> 'TranslatedText':
 79        if isinstance(v, cls):
 80            return v
 81        if isinstance(v, dict):
 82            return cls(v)
 83        return cls({default_lang: str(v)}) if v else cls()
 84
 85
 86def _tt_hash_str(v) -> str:
 87    """Stable string representation of a TranslatedText (or plain str) for hashing.  The
 88    instructor fragments are left out: a hash is the same in and out of the instructor mode."""
 89    return json.dumps(dict(sorted(strip_instructor(TranslatedText.from_value(v)).items())), ensure_ascii=False)
 90
 91
 92class QuestionType(Enum):
 93    DUMMY = 0
 94    TEXT = 1
 95    FORM = 2
 96
 97
 98class Question:
 99    """Abstract base for all question types (TEXT, FORM, DUMMY)."""
100
101    def __init__(self, title, description, question_hash, order, question_type):
102        self.title = title
103        self.description = description
104        self.question_hash = question_hash
105        self.order = order
106        self.question_type = question_type
107
108
109
110@dataclass
111class QuestionText(Question):
112    """A free-text answer question.  Hash is derived from *title* if not provided."""
113
114    title: TranslatedText | str
115    description: TranslatedText | str
116    question_hash: str
117    order: int
118    question_type: int = QuestionType.TEXT.value
119
120    def __init__(self, title, description='', question_hash=None, order=None, **kwargs):
121        super().__init__(title, description, question_hash, order, question_type=QuestionType.TEXT)
122        if question_hash is None:
123            self.question_hash = hashlib.sha256(_tt_hash_str(title).encode('UTF-8')).hexdigest()
124        else:
125            self.question_hash = question_hash
126        self.question_type = QuestionType.TEXT.value
127
128    def to_json(self) -> str:
129        return json.dumps(asdict(self))
130
131    @classmethod
132    def from_json(cls, json_data: str) -> "QuestionText":
133        d = json.loads(json_data)
134        if isinstance(d.get('title'), dict):
135            d['title'] = TranslatedText(d['title'])
136        if isinstance(d.get('description'), dict):
137            d['description'] = TranslatedText(d['description'])
138        return cls(**d)
139
140
141@dataclass
142class QuestionDummy(Question):
143    """A display-only block (no answer input).  Hash derived from title + description."""
144
145    title: TranslatedText | str
146    description: TranslatedText | str
147    question_hash: str
148    order: int
149    question_type: int = QuestionType.DUMMY.value
150
151    def __init__(self, title, description, question_hash=None, order=None, **kwargs):
152        super().__init__(title, description, question_hash, order, question_type=QuestionType.DUMMY)
153        if question_hash is None:
154            self.question_hash = hashlib.sha256(
155                (_tt_hash_str(title) + '--' + _tt_hash_str(description)).encode('UTF-8')
156            ).hexdigest()
157        else:
158            self.question_hash = question_hash
159        self.question_type = QuestionType.DUMMY.value
160
161    def to_json(self) -> str:
162        return json.dumps(asdict(self))
163
164    @classmethod
165    def from_json(cls, json_data: str) -> "QuestionDummy":
166        d = json.loads(json_data)
167        if isinstance(d.get('title'), dict):
168            d['title'] = TranslatedText(d['title'])
169        if isinstance(d.get('description'), dict):
170            d['description'] = TranslatedText(d['description'])
171        return cls(**d)
172
173
174@dataclass
175class QuestionForm(Question):
176    """A structured-form question with ``@@{field:spec}@@`` inline markers.
177
178    ``fields`` is a list of dicts, one per marker: ``{"name": str, "regex": str}`` for a
179    single-line text input, ``{"name": str, "lines": int}`` for a multi-line text input
180    (spec ``+`` or ``+N``, see ``form_field_lines()``), ``{"name": str, "choices": [...]}`` for
181    a dropdown and ``{"name": str, "checkbox": bool}`` for a checkbox (its default state).
182    """
183
184    title: TranslatedText | str
185    description: TranslatedText | str
186    question_hash: str
187    order: int
188    fields: list  # [{"name": str, "regex": str}, ...]
189    question_type: int = QuestionType.FORM.value
190
191    def __init__(self, title, description, question_hash=None, order=None, fields=None, **kwargs):
192        super().__init__(title, description, question_hash, order, question_type=QuestionType.FORM)
193        if question_hash is None:
194            self.question_hash = hashlib.sha256(
195                (_tt_hash_str(title) + '--' + _tt_hash_str(description)).encode('UTF-8')
196            ).hexdigest()
197        else:
198            self.question_hash = question_hash
199        self.fields = fields or []
200        self.question_type = QuestionType.FORM.value
201
202    def to_json(self) -> str:
203        return json.dumps(asdict(self))
204
205    @classmethod
206    def from_json(cls, json_data: str) -> "QuestionForm":
207        d = json.loads(json_data)
208        if isinstance(d.get('title'), dict):
209            d['title'] = TranslatedText(d['title'])
210        if isinstance(d.get('description'), dict):
211            d['description'] = TranslatedText(d['description'])
212        return cls(**d)
213
214
215FORM_MULTILINE_PREFIX = '+'
216
217
218def form_field_lines(spec: str) -> int | None:
219    """Visible lines of a multi-line form field spec (``+`` or ``+N``), None for any other spec.
220
221    ``+`` and ``+0`` give ``params.default_form_field_lines``; what follows the digits is ignored.
222    The one definition of the grammar, shared by ``Grade0.question_form()`` and the GUI forms.
223    """
224    if not spec.startswith(FORM_MULTILINE_PREFIX):
225        return None
226    m = re.match(r'\s*(\d*)', spec[len(FORM_MULTILINE_PREFIX):])
227    return int(m.group(1) or 0) or params.default_form_field_lines
228
229
230@dataclass
231class GradeElement:
232    """One entry in a grade rubric.
233
234    Either numeric (``grade`` / ``max_grade``) or letter-graded (``grade_letter`` ∈ OK/MEH/FAIL).
235    Call ``to_grade_letter()`` to convert a numeric element to its letter equivalent.
236
237    ``scope`` is a bitmask: bit 0 (SELF_EVAL_SCOPE=1) makes the element visible in
238    user-triggered self-eval (``sre eval --auto-eval``); bit 1 (EXO_EVAL_SCOPE=2)
239    makes it visible in non-user-triggered eval, ``sre outline`` and ``sre sheet``.
240    Default 3 (BOTH_EVAL_SCOPE) means visible in both.
241    """
242
243    title: str
244    max_grade: float | None = None
245    grade: float | None = None
246    grade_letter: str | None = None
247    description: TranslatedText | str = ""
248    scope: int = 3
249    grade_part: str | None = None
250    bonus: bool = False  # the grade counts in the totals, the max_grade does not
251
252    def to_dict(self) -> dict:
253        return asdict(self)
254
255    @classmethod
256    def from_dict(cls, d: dict) -> "GradeElement":
257        d = dict(d)
258        if isinstance(d.get('description'), dict):
259            d['description'] = TranslatedText(d['description'])
260        return cls(**d)
261
262    def pack(self) -> bytes:
263        return msgpack.packb(self.to_dict(), use_bin_type=True)
264
265    @classmethod
266    def unpack(cls, data: bytes) -> "GradeElement":
267        return cls.from_dict(msgpack.unpackb(data, raw=False))
268
269    def to_json(self) -> str:
270        return json.dumps(asdict(self))
271
272    @classmethod
273    def from_json(cls, s: str) -> "GradeElement":
274        d = json.loads(s)
275        if isinstance(d.get('description'), dict):
276            d['description'] = TranslatedText(d['description'])
277        return cls(**d)
278
279    def to_grade_letter(self) -> "GradeElement":
280        if self.grade == self.max_grade:
281            letter = "OK"
282        elif self.grade is not None and self.grade != 0:
283            letter = "MEH"
284        else:
285            letter = "FAIL"
286        return GradeElement(title=self.title, max_grade=None, grade=None, grade_letter=letter,
287                            description=self.description, scope=self.scope,
288                            grade_part=self.grade_part, bonus=self.bonus)
289
290
291def bonus_texts(label: str, max_text: str, bonus: bool, in_prefix: bool = False,
292                word: str = "Bonus") -> tuple[str, str]:
293    """The ``(label, max)`` texts of the row of a grade element in a report.
294
295    Unchanged unless *bonus*: then the maximum reads ``"2 (Bonus)"`` (``"(Bonus)"`` when
296    *max_text* is empty, e.g. in letter mode), or, with *in_prefix*, the label reads
297    ``"Bonus: label"`` and the maximum is left untouched (``--bonus-in-prefix`` of
298    ``sre outline`` / ``sre sheet`` / ``sre watch``).  *word* is the already translated
299    "Bonus" of the caller (gettext, ``QObject.tr``).
300    """
301    if not bonus:
302        return label, max_text
303    if in_prefix:
304        return f"{word}: {label}", max_text
305    return label, f"{max_text} ({word})".strip()
306
307
308@dataclass
309class GradePart:
310    """A named group of GradeElements with a shared title and description.
311
312    Lab code registers parts via ``Grade.add_grade_part()`` and associates
313    each :class:`GradeElement` with one via the ``grade_part=`` kwarg on
314    ``add_grade_element``.  The element stores the part's *title* (not the
315    object) so it serializes cleanly to JSON / msgpack.
316    """
317
318    title: str
319    description: TranslatedText | str = ""
320
321    def to_dict(self) -> dict:
322        return asdict(self)
323
324    @classmethod
325    def from_dict(cls, d: dict) -> "GradePart":
326        d = dict(d)
327        if isinstance(d.get('description'), dict):
328            d['description'] = TranslatedText(d['description'])
329        return cls(**d)
330
331    def pack(self) -> bytes:
332        return msgpack.packb(self.to_dict(), use_bin_type=True)
333
334    @classmethod
335    def unpack(cls, data: bytes) -> "GradePart":
336        return cls.from_dict(msgpack.unpackb(data, raw=False))
337
338    def to_json(self) -> str:
339        return json.dumps(asdict(self))
340
341    @classmethod
342    def from_json(cls, s: str) -> "GradePart":
343        d = json.loads(s)
344        if isinstance(d.get('description'), dict):
345            d['description'] = TranslatedText(d['description'])
346        return cls(**d)
347
348
349@dataclass
350class InfoInterface:
351    """Network interface descriptor stored inside ``InfoMachine``."""
352
353    network: str
354    interface_name: str
355
356    def to_json(self) -> str:
357        return json.dumps(asdict(self))
358
359    @classmethod
360    def from_json(cls, json_data: str) -> "InfoInterface":
361        return cls(**json.loads(json_data))
362
363
364@dataclass
365class InfoMachine:
366    """Runtime snapshot of a single container, written to ``info.json`` by the CLI and read by the GUI."""
367
368    name: str
369    status: str
370    allow_connection: bool
371    hidden: bool
372    interfaces: List[InfoInterface]
373    ports: List[str]
374    bridged: bool
375    x11_host: bool = False
376    color: str = ""
377    shape: str = ""
378
379    def to_json(self):
380        return json.dumps(asdict(self))
381
382    @classmethod
383    def from_json(cls, json_data: str) -> "InfoMachine":
384        return cls(**json.loads(json_data))
385
386
387@dataclass
388class InfoSwitch:
389    """One network of the lab seen as the switch its machines are plugged into (``switches`` of
390    ``info.json``).  ``mode`` is one of ``params.network_modes``; ``allow_connection`` tells
391    whether students may open the console of a managed switch."""
392
393    name: str
394    mode: str
395    allow_connection: bool = True
396
397
398@dataclass
399class InfoLab:
400    """Full lab metadata written to ``info.json`` at ``sre start`` and polled by the GUI every second.
401
402    ``informations`` (not ``description``) holds the Markdown lab description.
403    ``questions`` is a list of ``QuestionText | QuestionDummy | QuestionForm`` instances.
404    """
405
406    lab_name: str
407    lab_hash: str
408    title: TranslatedText
409    informations: TranslatedText
410    export_kathara_project: bool
411    allow_self_grade: bool
412    machines: List[InfoMachine]
413    questions: List[Question]
414    delay_between_self_grade: int
415    eval_interval_without_exam_mode: int
416    eval_before_exit: bool
417    user_allowed_states: dict
418    debug_project: bool = False
419    instructor_mode: bool = False
420    allow_save_restore: bool = False
421    export_schema_pdf: bool = True
422    admin_only_states: list = field(default_factory=list)
423    default_language: str = ''
424    network_colors: dict = field(default_factory=dict)
425    network_shapes: dict = field(default_factory=dict)
426    show_nat_network: bool = False
427    nat_network_name: str = ''
428    nat_network_color: str = ''
429    host_network_exploded: bool = False
430    host_network_edge_relative_length: float = 1.0
431    schema_splines: str = 'curved'
432    schema_overlap: str = 'prism'
433    switches: List[InfoSwitch] = field(default_factory=list)
434
435    def to_json(self) -> str:
436        return json.dumps(asdict(self), indent=4)
437
438    @classmethod
439    def from_json(cls, s: str) -> "InfoLab":
440        d = json.loads(s)
441
442        def _wrap_q(item: dict) -> dict:
443            item = dict(item)
444            item['title'] = TranslatedText.from_value(item.get('title', ''))
445            item['description'] = TranslatedText.from_value(item.get('description', ''))
446            return item
447
448        return cls(
449            lab_name=d["lab_name"],
450            lab_hash=d["lab_hash"],
451            title=TranslatedText.from_value(d["title"]),
452            informations=TranslatedText.from_value(d.get("informations", "")),
453            export_kathara_project=d.get("export_kathara_project", True),
454            allow_self_grade=d.get("allow_self_grade", False),
455            debug_project=d.get("debug_project", False),
456            instructor_mode=d.get("instructor_mode", False),
457            allow_save_restore=d.get("allow_save_restore", False),
458            export_schema_pdf=d.get("export_schema_pdf", True),
459            delay_between_self_grade=d.get("delay_between_self_grade", 0),
460            eval_interval_without_exam_mode=d.get("eval_interval_without_exam_mode", 0),
461            eval_before_exit=d.get("eval_before_exit", False),
462            user_allowed_states=d.get("user_allowed_states", {}),
463            admin_only_states=list(d.get("admin_only_states", []) or []),
464            default_language=d.get("default_language", ''),
465            network_colors=d.get("network_colors", {}),
466            network_shapes=d.get("network_shapes", {}),
467            show_nat_network=d.get("show_nat_network", False),
468            nat_network_name=d.get("nat_network_name", ''),
469            nat_network_color=d.get("nat_network_color", ''),
470            host_network_exploded=d.get("host_network_exploded", False),
471            host_network_edge_relative_length=float(d.get("host_network_edge_relative_length", 1.0)),
472            schema_splines=d.get("schema_splines", "curved"),
473            schema_overlap=d.get("schema_overlap", "prism"),
474            machines=[InfoMachine(**item) for item in d["machines"]],
475            switches=[InfoSwitch(**item) for item in d.get("switches", []) or []],
476            questions=[
477                QuestionDummy(**_wrap_q(item)) if item.get("question_type") == QuestionType.DUMMY.value
478                else QuestionForm(**_wrap_q(item)) if item.get("question_type") == QuestionType.FORM.value
479                else QuestionText(**_wrap_q(item))
480                for item in d["questions"]
481            ],
482        )
483
484
485# ---------------------------------------------------------------------------------------------
486# Display helpers shared by the Qt GUI (sysreseval) and the web front-end (sreweb): pure
487# functions of the info.json dict, so that both show a project the same way.
488# ---------------------------------------------------------------------------------------------
489
490#: Identifiers of the tabs of a project, in display order.
491TAB_SCHEMA = "schema"
492TAB_FULL_SCHEMA = "full_schema"
493TAB_INFORMATIONS = "informations"
494TAB_MACHINES = "machines"
495TAB_NETWORKS = "networks"
496TAB_QUESTIONS = "questions"
497TAB_EVALUATION = "evaluation"
498TAB_TERMINALS = "terminals"
499TAB_DISPLAY = "display"          # the web only: the virtual X desktop of the project
500TAB_APPLY_CONFIG = "apply_config"
501TAB_LOG = "log"
502TAB_ORDER = (TAB_SCHEMA, TAB_FULL_SCHEMA, TAB_INFORMATIONS, TAB_MACHINES, TAB_NETWORKS, TAB_QUESTIONS,
503             TAB_EVALUATION, TAB_TERMINALS, TAB_DISPLAY, TAB_APPLY_CONFIG, TAB_LOG)
504
505
506def resolve_text(value, lang_priority: list, *, show_instructor: bool = False) -> str:
507    """A text of info.json (a string or a ``{lang: text}`` dict) in the first language of
508    *lang_priority* it has, as plain text: the instructor fragments are removed, or kept without
509    their marks with *show_instructor*."""
510    text = TranslatedText.from_value(value).resolve_priority(lang_priority)
511    return unwrap_instructor(text) if show_instructor else strip_instructor(text)
512
513
514def format_port(entry: str) -> str:
515    """Format ``host:machine/proto`` of ``InfoMachine.ports`` as ``host -> machine (proto)``."""
516    proto = "tcp"
517    if "/" in entry:
518        entry, proto = entry.rsplit("/", 1)
519    host_port, _, machine_port = entry.partition(":")
520    return f"{host_port} -> {machine_port} ({proto})"
521
522
523def format_ports(ports: list) -> str:
524    return "  -  ".join(format_port(p) for p in ports or [])
525
526
527def terminal_devices(info: dict) -> list:
528    """The devices of the Terminals tab: the machines of info.json, then the manageable switches
529    (their management console) as ``{name, allow_connection}`` dicts."""
530    switches = [{"name": s.get("name", ""), "allow_connection": s.get("allow_connection", False)}
531                for s in info.get("switches", []) or []
532                if s.get("mode") == params.network_mode_managed]
533    return list(info.get("machines", []) or []) + switches
534
535
536def info_languages(info: dict) -> set:
537    """The language codes of the translated texts of a project (title, informations, questions)."""
538    langs = set()
539    for key in ('title', 'informations'):
540        v = info.get(key)
541        if isinstance(v, dict):
542            langs.update(v.keys())
543    for q in info.get('questions', []) or []:
544        for field_name in ('title', 'description'):
545            v = q.get(field_name)
546            if isinstance(v, dict):
547                langs.update(v.keys())
548    return langs
549
550
551def project_title(info: dict, lang_priority: list, fallback: str = '') -> str:
552    """The title of a project tab: ``title`` of info.json in the user's language, else the lab
553    name without ``.py``, else *fallback* (the running lab name)."""
554    raw = info.get("title", info.get("lab_name", fallback).removesuffix('.py'))
555    return TranslatedText.from_value(raw).resolve_priority(lang_priority) or str(raw)
556
557
558def title_sort_key(text: str) -> tuple:
559    """The key of the alphabetical order of the labels of the lab tree (``"@sort": "title"`` in
560    a ``titles.json``): accents and case ignored, the runs of digits compared as numbers ("TP 2"
561    before "TP 10"). The even positions of the tuple hold text, the odd ones integers, so two
562    keys always compare."""
563    decomposed = unicodedata.normalize('NFKD', text or '')
564    plain = ''.join(c for c in decomposed if not unicodedata.combining(c)).casefold().strip()
565    return tuple(int(part) if i % 2 else part for i, part in enumerate(re.split(r'(\d+)', plain)))
566
567
568def order_tree_entries(children: list, mode: str) -> list:
569    """The children of one directory of the lab tree in display order: the sub-directories first,
570    then the labs; within each group, by label in the ``title`` mode (``title_sort_key``, then
571    ``name``), else in arrival order (``index``: ``sre list --with-titles`` already gives the
572    ``name`` and ``manual`` orders, which the consumers must not sort again). Each child is a
573    dict with ``kind`` (``params.lab_list_kind_dir`` / ``_lab``), ``name``, ``label`` and
574    ``index``; the dicts come back as they are."""
575    def key(child):
576        group = 0 if child.get('kind') == params.lab_list_kind_dir else 1
577        if mode == params.titles_sort_title:
578            return (group, title_sort_key(child.get('label') or ''), child.get('name') or '')
579        return (group, child.get('index', 0))
580    return sorted(children, key=key)
581
582
583def listed_states(user_allowed_states: dict, admin_only_states, *, show_admin_only: bool) -> dict:
584    """The states of the Apply Configuration tab: every state of ``user_allowed_states``, or only
585    those a student could apply when *show_admin_only* is false (project in instructor mode while
586    the "Instructor mode" button is off)."""
587    if show_admin_only:
588        return dict(user_allowed_states or {})
589    admin_only = set(admin_only_states or [])
590    return {state: description for state, description in (user_allowed_states or {}).items()
591            if state not in admin_only}
592
593
594def state_rows(user_allowed_states: dict, admin_only_states, *, show_admin_only: bool,
595               lang_priority: list) -> list:
596    """The rows of the Apply Configuration tab: ``(state, label, admin_only)`` tuples, the label
597    being the description of the state in the user's language, else the state name."""
598    admin_only = set(admin_only_states or [])
599    rows = []
600    for state, description in listed_states(user_allowed_states, admin_only_states,
601                                            show_admin_only=show_admin_only).items():
602        resolved = (TranslatedText.from_value(description).resolve_priority(lang_priority)
603                    if description else '')
604        rows.append((state, resolved if resolved else state, state in admin_only))
605    return rows
606
607
608def visible_tabs(info: dict, *, exam_mode: bool, instructor_shown: bool, display: bool = False,
609                 evaluation: bool | None = None) -> list:
610    """The tabs shown for a project, in display order (the ``TAB_*`` identifiers).
611
612    *instructor_shown*: the project has instructor texts (instructor mode or debug project) and
613    the "Instructor mode" button is on.  A debug project always gets the Full Schema, every state
614    and the Log; the Evaluation tab is hidden in exam mode and when the lab does not allow
615    self-evaluation (*evaluation* forces it on or off whatever the lab says: the web shows an
616    instructor the last archived evaluation of any project); the Apply Configuration tab needs
617    listed states (and not an exam, unless the instructor view is on).  *display*: the web only,
618    the Display tab of the virtual X desktop (after the Terminals); the Qt GUI never asks for it."""
619    debug = bool(info.get("debug_project", False))
620    if evaluation is None:
621        evaluation = bool(info.get("allow_self_grade", True))
622    tabs = [TAB_SCHEMA]
623    if debug:
624        tabs.append(TAB_FULL_SCHEMA)
625    tabs += [TAB_INFORMATIONS, TAB_MACHINES, TAB_NETWORKS]
626    if info.get("questions"):
627        tabs.append(TAB_QUESTIONS)
628    if not exam_mode and evaluation:
629        tabs.append(TAB_EVALUATION)
630    tabs.append(TAB_TERMINALS)
631    if display:
632        tabs.append(TAB_DISPLAY)
633    states = listed_states(info.get("user_allowed_states", {}), info.get("admin_only_states", []),
634                           show_admin_only=debug or instructor_shown)
635    if debug or ((instructor_shown or not exam_mode) and bool(states)):
636        tabs.append(TAB_APPLY_CONFIG)
637    if debug or instructor_shown:
638        tabs.append(TAB_LOG)
639    return tabs
640
641
642# ---------------------------------------------------------------------------
643# Project ownership (the user mode of the CLI, the project list of the GUI)
644# ---------------------------------------------------------------------------
645
646def project_accessible(running_lab_name: str, username: str | None) -> bool:
647    """Whether *username*, in user mode, may see and act on the project *running_lab_name*
648    (:func:`SRE.access.may_access_project` with ``params.check_project_owner``: their own
649    projects and those of root or the sre user, every project when the check is off).
650    Privileged callers are never asked: the CLI calls this in user mode only, the GUI always
651    (it goes through sre-wrapper)."""
652    return access.may_access_project(params.get_username_from_running_lab_name(running_lab_name),
653                                     username or '', check_owner=params.check_project_owner,
654                                     shared_owners=('root', params.sre_user))
SWITCH_ICON_SIZE = ('0.5', '0.5')
NETWORK_ICON_SIZE = ('1.0', '0.6')
def icon_node_attrs(image: str, color: str | None, width: str, height: str) -> dict:
20def icon_node_attrs(image: str, color: str | None, width: str, height: str) -> dict:
21    """Graphviz attributes of a network drawn as an icon with its label below: a box filled with
22    its colour behind the image when it has one, nothing otherwise."""
23    attrs = {"image": image, "labelloc": "b", "width": width, "height": height,
24             "fixedsize": "true", "imagescale": "true", "fontsize": "16"}
25    if color:
26        attrs.update(shape="box", style="filled", fillcolor=color)
27    else:
28        attrs["shape"] = "none"
29    return attrs

Graphviz attributes of a network drawn as an icon with its label below: a box filled with its colour behind the image when it has one, nothing otherwise.

class TranslatedText(builtins.dict):
32class TranslatedText(dict):
33    """A dict subclass holding translations keyed by language code (e.g. 'en', 'fr').
34
35    Use ``resolve(lang)`` to get the text for a specific language, falling back to the
36    first available translation.  ``from_value`` ensures backward-compat with plain strings.
37    """
38
39    def resolve(self, lang: str, fallback: str = '') -> str:
40        return self.get(lang) or next(iter(self.values()), fallback)
41
42    def resolve_priority(self, langs: list, fallback: str = '') -> str:
43        """Return the first translation found in the priority list, or first available."""
44        for lang in langs:
45            if self.get(lang):
46                return self[lang]
47        return next(iter(self.values()), fallback)
48
49    def __add__(self, other) -> 'TranslatedText':
50        if isinstance(other, str):
51            return TranslatedText({lang: self[lang] + other for lang in self})
52        if not isinstance(other, TranslatedText):
53            return NotImplemented
54        if not self:
55            return TranslatedText(other)
56        if not other:
57            return TranslatedText(self)
58        # Merge mismatched language sets gracefully (partial translations): take
59        # the union of languages, falling back to each side's default-language
60        # (first-key) value for a language that side lacks.
61        self_default = next(iter(self.values()))
62        other_default = next(iter(other.values()))
63        langs = list(dict.fromkeys((*self, *other)))
64        return TranslatedText({
65            lang: self.get(lang, self_default) + other.get(lang, other_default)
66            for lang in langs
67        })
68
69    def __radd__(self, other) -> 'TranslatedText':
70        if isinstance(other, str):
71            return TranslatedText({lang: other + self[lang] for lang in self})
72        return NotImplemented
73
74    def format(self, **kwargs) -> 'TranslatedText':
75        """Apply str.format(**kwargs) to every language value."""
76        return TranslatedText({lang: text.format(**kwargs) for lang, text in self.items()})
77
78    @classmethod
79    def from_value(cls, v, default_lang: str = 'en') -> 'TranslatedText':
80        if isinstance(v, cls):
81            return v
82        if isinstance(v, dict):
83            return cls(v)
84        return cls({default_lang: str(v)}) if v else cls()

A dict subclass holding translations keyed by language code (e.g. 'en', 'fr').

Use resolve(lang) to get the text for a specific language, falling back to the first available translation. from_value ensures backward-compat with plain strings.

def resolve(self, lang: str, fallback: str = '') -> str:
39    def resolve(self, lang: str, fallback: str = '') -> str:
40        return self.get(lang) or next(iter(self.values()), fallback)
def resolve_priority(self, langs: list, fallback: str = '') -> str:
42    def resolve_priority(self, langs: list, fallback: str = '') -> str:
43        """Return the first translation found in the priority list, or first available."""
44        for lang in langs:
45            if self.get(lang):
46                return self[lang]
47        return next(iter(self.values()), fallback)

Return the first translation found in the priority list, or first available.

def format(self, **kwargs) -> TranslatedText:
74    def format(self, **kwargs) -> 'TranslatedText':
75        """Apply str.format(**kwargs) to every language value."""
76        return TranslatedText({lang: text.format(**kwargs) for lang, text in self.items()})

Apply str.format(**kwargs) to every language value.

@classmethod
def from_value(cls, v, default_lang: str = 'en') -> TranslatedText:
78    @classmethod
79    def from_value(cls, v, default_lang: str = 'en') -> 'TranslatedText':
80        if isinstance(v, cls):
81            return v
82        if isinstance(v, dict):
83            return cls(v)
84        return cls({default_lang: str(v)}) if v else cls()
class QuestionType(enum.Enum):
93class QuestionType(Enum):
94    DUMMY = 0
95    TEXT = 1
96    FORM = 2
DUMMY = <QuestionType.DUMMY: 0>
TEXT = <QuestionType.TEXT: 1>
FORM = <QuestionType.FORM: 2>
class Question:
 99class Question:
100    """Abstract base for all question types (TEXT, FORM, DUMMY)."""
101
102    def __init__(self, title, description, question_hash, order, question_type):
103        self.title = title
104        self.description = description
105        self.question_hash = question_hash
106        self.order = order
107        self.question_type = question_type

Abstract base for all question types (TEXT, FORM, DUMMY).

Question(title, description, question_hash, order, question_type)
102    def __init__(self, title, description, question_hash, order, question_type):
103        self.title = title
104        self.description = description
105        self.question_hash = question_hash
106        self.order = order
107        self.question_type = question_type
title
description
question_hash
order
question_type
@dataclass
class QuestionText(Question):
111@dataclass
112class QuestionText(Question):
113    """A free-text answer question.  Hash is derived from *title* if not provided."""
114
115    title: TranslatedText | str
116    description: TranslatedText | str
117    question_hash: str
118    order: int
119    question_type: int = QuestionType.TEXT.value
120
121    def __init__(self, title, description='', question_hash=None, order=None, **kwargs):
122        super().__init__(title, description, question_hash, order, question_type=QuestionType.TEXT)
123        if question_hash is None:
124            self.question_hash = hashlib.sha256(_tt_hash_str(title).encode('UTF-8')).hexdigest()
125        else:
126            self.question_hash = question_hash
127        self.question_type = QuestionType.TEXT.value
128
129    def to_json(self) -> str:
130        return json.dumps(asdict(self))
131
132    @classmethod
133    def from_json(cls, json_data: str) -> "QuestionText":
134        d = json.loads(json_data)
135        if isinstance(d.get('title'), dict):
136            d['title'] = TranslatedText(d['title'])
137        if isinstance(d.get('description'), dict):
138            d['description'] = TranslatedText(d['description'])
139        return cls(**d)

A free-text answer question. Hash is derived from title if not provided.

QuestionText(title, description='', question_hash=None, order=None, **kwargs)
121    def __init__(self, title, description='', question_hash=None, order=None, **kwargs):
122        super().__init__(title, description, question_hash, order, question_type=QuestionType.TEXT)
123        if question_hash is None:
124            self.question_hash = hashlib.sha256(_tt_hash_str(title).encode('UTF-8')).hexdigest()
125        else:
126            self.question_hash = question_hash
127        self.question_type = QuestionType.TEXT.value
title: TranslatedText | str
description: TranslatedText | str
question_hash: str
order: int
question_type: int = 1
def to_json(self) -> str:
129    def to_json(self) -> str:
130        return json.dumps(asdict(self))
@classmethod
def from_json(cls, json_data: str) -> QuestionText:
132    @classmethod
133    def from_json(cls, json_data: str) -> "QuestionText":
134        d = json.loads(json_data)
135        if isinstance(d.get('title'), dict):
136            d['title'] = TranslatedText(d['title'])
137        if isinstance(d.get('description'), dict):
138            d['description'] = TranslatedText(d['description'])
139        return cls(**d)
@dataclass
class QuestionDummy(Question):
142@dataclass
143class QuestionDummy(Question):
144    """A display-only block (no answer input).  Hash derived from title + description."""
145
146    title: TranslatedText | str
147    description: TranslatedText | str
148    question_hash: str
149    order: int
150    question_type: int = QuestionType.DUMMY.value
151
152    def __init__(self, title, description, question_hash=None, order=None, **kwargs):
153        super().__init__(title, description, question_hash, order, question_type=QuestionType.DUMMY)
154        if question_hash is None:
155            self.question_hash = hashlib.sha256(
156                (_tt_hash_str(title) + '--' + _tt_hash_str(description)).encode('UTF-8')
157            ).hexdigest()
158        else:
159            self.question_hash = question_hash
160        self.question_type = QuestionType.DUMMY.value
161
162    def to_json(self) -> str:
163        return json.dumps(asdict(self))
164
165    @classmethod
166    def from_json(cls, json_data: str) -> "QuestionDummy":
167        d = json.loads(json_data)
168        if isinstance(d.get('title'), dict):
169            d['title'] = TranslatedText(d['title'])
170        if isinstance(d.get('description'), dict):
171            d['description'] = TranslatedText(d['description'])
172        return cls(**d)

A display-only block (no answer input). Hash derived from title + description.

QuestionDummy(title, description, question_hash=None, order=None, **kwargs)
152    def __init__(self, title, description, question_hash=None, order=None, **kwargs):
153        super().__init__(title, description, question_hash, order, question_type=QuestionType.DUMMY)
154        if question_hash is None:
155            self.question_hash = hashlib.sha256(
156                (_tt_hash_str(title) + '--' + _tt_hash_str(description)).encode('UTF-8')
157            ).hexdigest()
158        else:
159            self.question_hash = question_hash
160        self.question_type = QuestionType.DUMMY.value
title: TranslatedText | str
description: TranslatedText | str
question_hash: str
order: int
question_type: int = 0
def to_json(self) -> str:
162    def to_json(self) -> str:
163        return json.dumps(asdict(self))
@classmethod
def from_json(cls, json_data: str) -> QuestionDummy:
165    @classmethod
166    def from_json(cls, json_data: str) -> "QuestionDummy":
167        d = json.loads(json_data)
168        if isinstance(d.get('title'), dict):
169            d['title'] = TranslatedText(d['title'])
170        if isinstance(d.get('description'), dict):
171            d['description'] = TranslatedText(d['description'])
172        return cls(**d)
@dataclass
class QuestionForm(Question):
175@dataclass
176class QuestionForm(Question):
177    """A structured-form question with ``@@{field:spec}@@`` inline markers.
178
179    ``fields`` is a list of dicts, one per marker: ``{"name": str, "regex": str}`` for a
180    single-line text input, ``{"name": str, "lines": int}`` for a multi-line text input
181    (spec ``+`` or ``+N``, see ``form_field_lines()``), ``{"name": str, "choices": [...]}`` for
182    a dropdown and ``{"name": str, "checkbox": bool}`` for a checkbox (its default state).
183    """
184
185    title: TranslatedText | str
186    description: TranslatedText | str
187    question_hash: str
188    order: int
189    fields: list  # [{"name": str, "regex": str}, ...]
190    question_type: int = QuestionType.FORM.value
191
192    def __init__(self, title, description, question_hash=None, order=None, fields=None, **kwargs):
193        super().__init__(title, description, question_hash, order, question_type=QuestionType.FORM)
194        if question_hash is None:
195            self.question_hash = hashlib.sha256(
196                (_tt_hash_str(title) + '--' + _tt_hash_str(description)).encode('UTF-8')
197            ).hexdigest()
198        else:
199            self.question_hash = question_hash
200        self.fields = fields or []
201        self.question_type = QuestionType.FORM.value
202
203    def to_json(self) -> str:
204        return json.dumps(asdict(self))
205
206    @classmethod
207    def from_json(cls, json_data: str) -> "QuestionForm":
208        d = json.loads(json_data)
209        if isinstance(d.get('title'), dict):
210            d['title'] = TranslatedText(d['title'])
211        if isinstance(d.get('description'), dict):
212            d['description'] = TranslatedText(d['description'])
213        return cls(**d)

A structured-form question with @@{field:spec}@@ inline markers.

fields is a list of dicts, one per marker: {"name": str, "regex": str} for a single-line text input, {"name": str, "lines": int} for a multi-line text input (spec + or +N, see form_field_lines()), {"name": str, "choices": [...]} for a dropdown and {"name": str, "checkbox": bool} for a checkbox (its default state).

QuestionForm( title, description, question_hash=None, order=None, fields=None, **kwargs)
192    def __init__(self, title, description, question_hash=None, order=None, fields=None, **kwargs):
193        super().__init__(title, description, question_hash, order, question_type=QuestionType.FORM)
194        if question_hash is None:
195            self.question_hash = hashlib.sha256(
196                (_tt_hash_str(title) + '--' + _tt_hash_str(description)).encode('UTF-8')
197            ).hexdigest()
198        else:
199            self.question_hash = question_hash
200        self.fields = fields or []
201        self.question_type = QuestionType.FORM.value
title: TranslatedText | str
description: TranslatedText | str
question_hash: str
order: int
fields: list
question_type: int = 2
def to_json(self) -> str:
203    def to_json(self) -> str:
204        return json.dumps(asdict(self))
@classmethod
def from_json(cls, json_data: str) -> QuestionForm:
206    @classmethod
207    def from_json(cls, json_data: str) -> "QuestionForm":
208        d = json.loads(json_data)
209        if isinstance(d.get('title'), dict):
210            d['title'] = TranslatedText(d['title'])
211        if isinstance(d.get('description'), dict):
212            d['description'] = TranslatedText(d['description'])
213        return cls(**d)
FORM_MULTILINE_PREFIX = '+'
def form_field_lines(spec: str) -> int | None:
219def form_field_lines(spec: str) -> int | None:
220    """Visible lines of a multi-line form field spec (``+`` or ``+N``), None for any other spec.
221
222    ``+`` and ``+0`` give ``params.default_form_field_lines``; what follows the digits is ignored.
223    The one definition of the grammar, shared by ``Grade0.question_form()`` and the GUI forms.
224    """
225    if not spec.startswith(FORM_MULTILINE_PREFIX):
226        return None
227    m = re.match(r'\s*(\d*)', spec[len(FORM_MULTILINE_PREFIX):])
228    return int(m.group(1) or 0) or params.default_form_field_lines

Visible lines of a multi-line form field spec (+ or +N), None for any other spec.

+ and +0 give params.default_form_field_lines; what follows the digits is ignored. The one definition of the grammar, shared by Grade0.question_form() and the GUI forms.

@dataclass
class GradeElement:
231@dataclass
232class GradeElement:
233    """One entry in a grade rubric.
234
235    Either numeric (``grade`` / ``max_grade``) or letter-graded (``grade_letter`` ∈ OK/MEH/FAIL).
236    Call ``to_grade_letter()`` to convert a numeric element to its letter equivalent.
237
238    ``scope`` is a bitmask: bit 0 (SELF_EVAL_SCOPE=1) makes the element visible in
239    user-triggered self-eval (``sre eval --auto-eval``); bit 1 (EXO_EVAL_SCOPE=2)
240    makes it visible in non-user-triggered eval, ``sre outline`` and ``sre sheet``.
241    Default 3 (BOTH_EVAL_SCOPE) means visible in both.
242    """
243
244    title: str
245    max_grade: float | None = None
246    grade: float | None = None
247    grade_letter: str | None = None
248    description: TranslatedText | str = ""
249    scope: int = 3
250    grade_part: str | None = None
251    bonus: bool = False  # the grade counts in the totals, the max_grade does not
252
253    def to_dict(self) -> dict:
254        return asdict(self)
255
256    @classmethod
257    def from_dict(cls, d: dict) -> "GradeElement":
258        d = dict(d)
259        if isinstance(d.get('description'), dict):
260            d['description'] = TranslatedText(d['description'])
261        return cls(**d)
262
263    def pack(self) -> bytes:
264        return msgpack.packb(self.to_dict(), use_bin_type=True)
265
266    @classmethod
267    def unpack(cls, data: bytes) -> "GradeElement":
268        return cls.from_dict(msgpack.unpackb(data, raw=False))
269
270    def to_json(self) -> str:
271        return json.dumps(asdict(self))
272
273    @classmethod
274    def from_json(cls, s: str) -> "GradeElement":
275        d = json.loads(s)
276        if isinstance(d.get('description'), dict):
277            d['description'] = TranslatedText(d['description'])
278        return cls(**d)
279
280    def to_grade_letter(self) -> "GradeElement":
281        if self.grade == self.max_grade:
282            letter = "OK"
283        elif self.grade is not None and self.grade != 0:
284            letter = "MEH"
285        else:
286            letter = "FAIL"
287        return GradeElement(title=self.title, max_grade=None, grade=None, grade_letter=letter,
288                            description=self.description, scope=self.scope,
289                            grade_part=self.grade_part, bonus=self.bonus)

One entry in a grade rubric.

Either numeric (grade / max_grade) or letter-graded (grade_letter ∈ OK/MEH/FAIL). Call to_grade_letter() to convert a numeric element to its letter equivalent.

scope is a bitmask: bit 0 (SELF_EVAL_SCOPE=1) makes the element visible in user-triggered self-eval (sre eval --auto-eval); bit 1 (EXO_EVAL_SCOPE=2) makes it visible in non-user-triggered eval, sre outline and sre sheet. Default 3 (BOTH_EVAL_SCOPE) means visible in both.

GradeElement( title: str, max_grade: float | None = None, grade: float | None = None, grade_letter: str | None = None, description: TranslatedText | str = '', scope: int = 3, grade_part: str | None = None, bonus: bool = False)
title: str
max_grade: float | None = None
grade: float | None = None
grade_letter: str | None = None
description: TranslatedText | str = ''
scope: int = 3
grade_part: str | None = None
bonus: bool = False
def to_dict(self) -> dict:
253    def to_dict(self) -> dict:
254        return asdict(self)
@classmethod
def from_dict(cls, d: dict) -> GradeElement:
256    @classmethod
257    def from_dict(cls, d: dict) -> "GradeElement":
258        d = dict(d)
259        if isinstance(d.get('description'), dict):
260            d['description'] = TranslatedText(d['description'])
261        return cls(**d)
def pack(self) -> bytes:
263    def pack(self) -> bytes:
264        return msgpack.packb(self.to_dict(), use_bin_type=True)
@classmethod
def unpack(cls, data: bytes) -> GradeElement:
266    @classmethod
267    def unpack(cls, data: bytes) -> "GradeElement":
268        return cls.from_dict(msgpack.unpackb(data, raw=False))
def to_json(self) -> str:
270    def to_json(self) -> str:
271        return json.dumps(asdict(self))
@classmethod
def from_json(cls, s: str) -> GradeElement:
273    @classmethod
274    def from_json(cls, s: str) -> "GradeElement":
275        d = json.loads(s)
276        if isinstance(d.get('description'), dict):
277            d['description'] = TranslatedText(d['description'])
278        return cls(**d)
def to_grade_letter(self) -> GradeElement:
280    def to_grade_letter(self) -> "GradeElement":
281        if self.grade == self.max_grade:
282            letter = "OK"
283        elif self.grade is not None and self.grade != 0:
284            letter = "MEH"
285        else:
286            letter = "FAIL"
287        return GradeElement(title=self.title, max_grade=None, grade=None, grade_letter=letter,
288                            description=self.description, scope=self.scope,
289                            grade_part=self.grade_part, bonus=self.bonus)
def bonus_texts( label: str, max_text: str, bonus: bool, in_prefix: bool = False, word: str = 'Bonus') -> tuple[str, str]:
292def bonus_texts(label: str, max_text: str, bonus: bool, in_prefix: bool = False,
293                word: str = "Bonus") -> tuple[str, str]:
294    """The ``(label, max)`` texts of the row of a grade element in a report.
295
296    Unchanged unless *bonus*: then the maximum reads ``"2 (Bonus)"`` (``"(Bonus)"`` when
297    *max_text* is empty, e.g. in letter mode), or, with *in_prefix*, the label reads
298    ``"Bonus: label"`` and the maximum is left untouched (``--bonus-in-prefix`` of
299    ``sre outline`` / ``sre sheet`` / ``sre watch``).  *word* is the already translated
300    "Bonus" of the caller (gettext, ``QObject.tr``).
301    """
302    if not bonus:
303        return label, max_text
304    if in_prefix:
305        return f"{word}: {label}", max_text
306    return label, f"{max_text} ({word})".strip()

The (label, max) texts of the row of a grade element in a report.

Unchanged unless bonus: then the maximum reads "2 (Bonus)" ("(Bonus)" when max_text is empty, e.g. in letter mode), or, with in_prefix, the label reads "Bonus: label" and the maximum is left untouched (--bonus-in-prefix of sre outline / sre sheet / sre watch). word is the already translated "Bonus" of the caller (gettext, QObject.tr).

@dataclass
class GradePart:
309@dataclass
310class GradePart:
311    """A named group of GradeElements with a shared title and description.
312
313    Lab code registers parts via ``Grade.add_grade_part()`` and associates
314    each :class:`GradeElement` with one via the ``grade_part=`` kwarg on
315    ``add_grade_element``.  The element stores the part's *title* (not the
316    object) so it serializes cleanly to JSON / msgpack.
317    """
318
319    title: str
320    description: TranslatedText | str = ""
321
322    def to_dict(self) -> dict:
323        return asdict(self)
324
325    @classmethod
326    def from_dict(cls, d: dict) -> "GradePart":
327        d = dict(d)
328        if isinstance(d.get('description'), dict):
329            d['description'] = TranslatedText(d['description'])
330        return cls(**d)
331
332    def pack(self) -> bytes:
333        return msgpack.packb(self.to_dict(), use_bin_type=True)
334
335    @classmethod
336    def unpack(cls, data: bytes) -> "GradePart":
337        return cls.from_dict(msgpack.unpackb(data, raw=False))
338
339    def to_json(self) -> str:
340        return json.dumps(asdict(self))
341
342    @classmethod
343    def from_json(cls, s: str) -> "GradePart":
344        d = json.loads(s)
345        if isinstance(d.get('description'), dict):
346            d['description'] = TranslatedText(d['description'])
347        return cls(**d)

A named group of GradeElements with a shared title and description.

Lab code registers parts via Grade.add_grade_part() and associates each GradeElement with one via the grade_part= kwarg on add_grade_element. The element stores the part's title (not the object) so it serializes cleanly to JSON / msgpack.

GradePart(title: str, description: TranslatedText | str = '')
title: str
description: TranslatedText | str = ''
def to_dict(self) -> dict:
322    def to_dict(self) -> dict:
323        return asdict(self)
@classmethod
def from_dict(cls, d: dict) -> GradePart:
325    @classmethod
326    def from_dict(cls, d: dict) -> "GradePart":
327        d = dict(d)
328        if isinstance(d.get('description'), dict):
329            d['description'] = TranslatedText(d['description'])
330        return cls(**d)
def pack(self) -> bytes:
332    def pack(self) -> bytes:
333        return msgpack.packb(self.to_dict(), use_bin_type=True)
@classmethod
def unpack(cls, data: bytes) -> GradePart:
335    @classmethod
336    def unpack(cls, data: bytes) -> "GradePart":
337        return cls.from_dict(msgpack.unpackb(data, raw=False))
def to_json(self) -> str:
339    def to_json(self) -> str:
340        return json.dumps(asdict(self))
@classmethod
def from_json(cls, s: str) -> GradePart:
342    @classmethod
343    def from_json(cls, s: str) -> "GradePart":
344        d = json.loads(s)
345        if isinstance(d.get('description'), dict):
346            d['description'] = TranslatedText(d['description'])
347        return cls(**d)
@dataclass
class InfoInterface:
350@dataclass
351class InfoInterface:
352    """Network interface descriptor stored inside ``InfoMachine``."""
353
354    network: str
355    interface_name: str
356
357    def to_json(self) -> str:
358        return json.dumps(asdict(self))
359
360    @classmethod
361    def from_json(cls, json_data: str) -> "InfoInterface":
362        return cls(**json.loads(json_data))

Network interface descriptor stored inside InfoMachine.

InfoInterface(network: str, interface_name: str)
network: str
interface_name: str
def to_json(self) -> str:
357    def to_json(self) -> str:
358        return json.dumps(asdict(self))
@classmethod
def from_json(cls, json_data: str) -> InfoInterface:
360    @classmethod
361    def from_json(cls, json_data: str) -> "InfoInterface":
362        return cls(**json.loads(json_data))
@dataclass
class InfoMachine:
365@dataclass
366class InfoMachine:
367    """Runtime snapshot of a single container, written to ``info.json`` by the CLI and read by the GUI."""
368
369    name: str
370    status: str
371    allow_connection: bool
372    hidden: bool
373    interfaces: List[InfoInterface]
374    ports: List[str]
375    bridged: bool
376    x11_host: bool = False
377    color: str = ""
378    shape: str = ""
379
380    def to_json(self):
381        return json.dumps(asdict(self))
382
383    @classmethod
384    def from_json(cls, json_data: str) -> "InfoMachine":
385        return cls(**json.loads(json_data))

Runtime snapshot of a single container, written to info.json by the CLI and read by the GUI.

InfoMachine( name: str, status: str, allow_connection: bool, hidden: bool, interfaces: List[InfoInterface], ports: List[str], bridged: bool, x11_host: bool = False, color: str = '', shape: str = '')
name: str
status: str
allow_connection: bool
hidden: bool
interfaces: List[InfoInterface]
ports: List[str]
bridged: bool
x11_host: bool = False
color: str = ''
shape: str = ''
def to_json(self):
380    def to_json(self):
381        return json.dumps(asdict(self))
@classmethod
def from_json(cls, json_data: str) -> InfoMachine:
383    @classmethod
384    def from_json(cls, json_data: str) -> "InfoMachine":
385        return cls(**json.loads(json_data))
@dataclass
class InfoSwitch:
388@dataclass
389class InfoSwitch:
390    """One network of the lab seen as the switch its machines are plugged into (``switches`` of
391    ``info.json``).  ``mode`` is one of ``params.network_modes``; ``allow_connection`` tells
392    whether students may open the console of a managed switch."""
393
394    name: str
395    mode: str
396    allow_connection: bool = True

One network of the lab seen as the switch its machines are plugged into (switches of info.json). mode is one of params.network_modes; allow_connection tells whether students may open the console of a managed switch.

InfoSwitch(name: str, mode: str, allow_connection: bool = True)
name: str
mode: str
allow_connection: bool = True
@dataclass
class InfoLab:
399@dataclass
400class InfoLab:
401    """Full lab metadata written to ``info.json`` at ``sre start`` and polled by the GUI every second.
402
403    ``informations`` (not ``description``) holds the Markdown lab description.
404    ``questions`` is a list of ``QuestionText | QuestionDummy | QuestionForm`` instances.
405    """
406
407    lab_name: str
408    lab_hash: str
409    title: TranslatedText
410    informations: TranslatedText
411    export_kathara_project: bool
412    allow_self_grade: bool
413    machines: List[InfoMachine]
414    questions: List[Question]
415    delay_between_self_grade: int
416    eval_interval_without_exam_mode: int
417    eval_before_exit: bool
418    user_allowed_states: dict
419    debug_project: bool = False
420    instructor_mode: bool = False
421    allow_save_restore: bool = False
422    export_schema_pdf: bool = True
423    admin_only_states: list = field(default_factory=list)
424    default_language: str = ''
425    network_colors: dict = field(default_factory=dict)
426    network_shapes: dict = field(default_factory=dict)
427    show_nat_network: bool = False
428    nat_network_name: str = ''
429    nat_network_color: str = ''
430    host_network_exploded: bool = False
431    host_network_edge_relative_length: float = 1.0
432    schema_splines: str = 'curved'
433    schema_overlap: str = 'prism'
434    switches: List[InfoSwitch] = field(default_factory=list)
435
436    def to_json(self) -> str:
437        return json.dumps(asdict(self), indent=4)
438
439    @classmethod
440    def from_json(cls, s: str) -> "InfoLab":
441        d = json.loads(s)
442
443        def _wrap_q(item: dict) -> dict:
444            item = dict(item)
445            item['title'] = TranslatedText.from_value(item.get('title', ''))
446            item['description'] = TranslatedText.from_value(item.get('description', ''))
447            return item
448
449        return cls(
450            lab_name=d["lab_name"],
451            lab_hash=d["lab_hash"],
452            title=TranslatedText.from_value(d["title"]),
453            informations=TranslatedText.from_value(d.get("informations", "")),
454            export_kathara_project=d.get("export_kathara_project", True),
455            allow_self_grade=d.get("allow_self_grade", False),
456            debug_project=d.get("debug_project", False),
457            instructor_mode=d.get("instructor_mode", False),
458            allow_save_restore=d.get("allow_save_restore", False),
459            export_schema_pdf=d.get("export_schema_pdf", True),
460            delay_between_self_grade=d.get("delay_between_self_grade", 0),
461            eval_interval_without_exam_mode=d.get("eval_interval_without_exam_mode", 0),
462            eval_before_exit=d.get("eval_before_exit", False),
463            user_allowed_states=d.get("user_allowed_states", {}),
464            admin_only_states=list(d.get("admin_only_states", []) or []),
465            default_language=d.get("default_language", ''),
466            network_colors=d.get("network_colors", {}),
467            network_shapes=d.get("network_shapes", {}),
468            show_nat_network=d.get("show_nat_network", False),
469            nat_network_name=d.get("nat_network_name", ''),
470            nat_network_color=d.get("nat_network_color", ''),
471            host_network_exploded=d.get("host_network_exploded", False),
472            host_network_edge_relative_length=float(d.get("host_network_edge_relative_length", 1.0)),
473            schema_splines=d.get("schema_splines", "curved"),
474            schema_overlap=d.get("schema_overlap", "prism"),
475            machines=[InfoMachine(**item) for item in d["machines"]],
476            switches=[InfoSwitch(**item) for item in d.get("switches", []) or []],
477            questions=[
478                QuestionDummy(**_wrap_q(item)) if item.get("question_type") == QuestionType.DUMMY.value
479                else QuestionForm(**_wrap_q(item)) if item.get("question_type") == QuestionType.FORM.value
480                else QuestionText(**_wrap_q(item))
481                for item in d["questions"]
482            ],
483        )

Full lab metadata written to info.json at sre start and polled by the GUI every second.

informations (not description) holds the Markdown lab description. questions is a list of QuestionText | QuestionDummy | QuestionForm instances.

InfoLab( lab_name: str, lab_hash: str, title: TranslatedText, informations: TranslatedText, export_kathara_project: bool, allow_self_grade: bool, machines: List[InfoMachine], questions: List[Question], delay_between_self_grade: int, eval_interval_without_exam_mode: int, eval_before_exit: bool, user_allowed_states: dict, debug_project: bool = False, instructor_mode: bool = False, allow_save_restore: bool = False, export_schema_pdf: bool = True, admin_only_states: list = <factory>, default_language: str = '', network_colors: dict = <factory>, network_shapes: dict = <factory>, show_nat_network: bool = False, nat_network_name: str = '', nat_network_color: str = '', host_network_exploded: bool = False, host_network_edge_relative_length: float = 1.0, schema_splines: str = 'curved', schema_overlap: str = 'prism', switches: List[InfoSwitch] = <factory>)
lab_name: str
lab_hash: str
informations: TranslatedText
export_kathara_project: bool
allow_self_grade: bool
machines: List[InfoMachine]
questions: List[Question]
delay_between_self_grade: int
eval_interval_without_exam_mode: int
eval_before_exit: bool
user_allowed_states: dict
debug_project: bool = False
instructor_mode: bool = False
allow_save_restore: bool = False
export_schema_pdf: bool = True
admin_only_states: list
default_language: str = ''
network_colors: dict
network_shapes: dict
show_nat_network: bool = False
nat_network_name: str = ''
nat_network_color: str = ''
host_network_exploded: bool = False
host_network_edge_relative_length: float = 1.0
schema_splines: str = 'curved'
schema_overlap: str = 'prism'
switches: List[InfoSwitch]
def to_json(self) -> str:
436    def to_json(self) -> str:
437        return json.dumps(asdict(self), indent=4)
@classmethod
def from_json(cls, s: str) -> InfoLab:
439    @classmethod
440    def from_json(cls, s: str) -> "InfoLab":
441        d = json.loads(s)
442
443        def _wrap_q(item: dict) -> dict:
444            item = dict(item)
445            item['title'] = TranslatedText.from_value(item.get('title', ''))
446            item['description'] = TranslatedText.from_value(item.get('description', ''))
447            return item
448
449        return cls(
450            lab_name=d["lab_name"],
451            lab_hash=d["lab_hash"],
452            title=TranslatedText.from_value(d["title"]),
453            informations=TranslatedText.from_value(d.get("informations", "")),
454            export_kathara_project=d.get("export_kathara_project", True),
455            allow_self_grade=d.get("allow_self_grade", False),
456            debug_project=d.get("debug_project", False),
457            instructor_mode=d.get("instructor_mode", False),
458            allow_save_restore=d.get("allow_save_restore", False),
459            export_schema_pdf=d.get("export_schema_pdf", True),
460            delay_between_self_grade=d.get("delay_between_self_grade", 0),
461            eval_interval_without_exam_mode=d.get("eval_interval_without_exam_mode", 0),
462            eval_before_exit=d.get("eval_before_exit", False),
463            user_allowed_states=d.get("user_allowed_states", {}),
464            admin_only_states=list(d.get("admin_only_states", []) or []),
465            default_language=d.get("default_language", ''),
466            network_colors=d.get("network_colors", {}),
467            network_shapes=d.get("network_shapes", {}),
468            show_nat_network=d.get("show_nat_network", False),
469            nat_network_name=d.get("nat_network_name", ''),
470            nat_network_color=d.get("nat_network_color", ''),
471            host_network_exploded=d.get("host_network_exploded", False),
472            host_network_edge_relative_length=float(d.get("host_network_edge_relative_length", 1.0)),
473            schema_splines=d.get("schema_splines", "curved"),
474            schema_overlap=d.get("schema_overlap", "prism"),
475            machines=[InfoMachine(**item) for item in d["machines"]],
476            switches=[InfoSwitch(**item) for item in d.get("switches", []) or []],
477            questions=[
478                QuestionDummy(**_wrap_q(item)) if item.get("question_type") == QuestionType.DUMMY.value
479                else QuestionForm(**_wrap_q(item)) if item.get("question_type") == QuestionType.FORM.value
480                else QuestionText(**_wrap_q(item))
481                for item in d["questions"]
482            ],
483        )
TAB_SCHEMA = 'schema'
TAB_FULL_SCHEMA = 'full_schema'
TAB_INFORMATIONS = 'informations'
TAB_MACHINES = 'machines'
TAB_NETWORKS = 'networks'
TAB_QUESTIONS = 'questions'
TAB_EVALUATION = 'evaluation'
TAB_TERMINALS = 'terminals'
TAB_DISPLAY = 'display'
TAB_APPLY_CONFIG = 'apply_config'
TAB_LOG = 'log'
TAB_ORDER = ('schema', 'full_schema', 'informations', 'machines', 'networks', 'questions', 'evaluation', 'terminals', 'display', 'apply_config', 'log')
def resolve_text(value, lang_priority: list, *, show_instructor: bool = False) -> str:
507def resolve_text(value, lang_priority: list, *, show_instructor: bool = False) -> str:
508    """A text of info.json (a string or a ``{lang: text}`` dict) in the first language of
509    *lang_priority* it has, as plain text: the instructor fragments are removed, or kept without
510    their marks with *show_instructor*."""
511    text = TranslatedText.from_value(value).resolve_priority(lang_priority)
512    return unwrap_instructor(text) if show_instructor else strip_instructor(text)

A text of info.json (a string or a {lang: text} dict) in the first language of lang_priority it has, as plain text: the instructor fragments are removed, or kept without their marks with show_instructor.

def format_port(entry: str) -> str:
515def format_port(entry: str) -> str:
516    """Format ``host:machine/proto`` of ``InfoMachine.ports`` as ``host -> machine (proto)``."""
517    proto = "tcp"
518    if "/" in entry:
519        entry, proto = entry.rsplit("/", 1)
520    host_port, _, machine_port = entry.partition(":")
521    return f"{host_port} -> {machine_port} ({proto})"

Format host:machine/proto of InfoMachine.ports as host -> machine (proto).

def format_ports(ports: list) -> str:
524def format_ports(ports: list) -> str:
525    return "  -  ".join(format_port(p) for p in ports or [])
def terminal_devices(info: dict) -> list:
528def terminal_devices(info: dict) -> list:
529    """The devices of the Terminals tab: the machines of info.json, then the manageable switches
530    (their management console) as ``{name, allow_connection}`` dicts."""
531    switches = [{"name": s.get("name", ""), "allow_connection": s.get("allow_connection", False)}
532                for s in info.get("switches", []) or []
533                if s.get("mode") == params.network_mode_managed]
534    return list(info.get("machines", []) or []) + switches

The devices of the Terminals tab: the machines of info.json, then the manageable switches (their management console) as {name, allow_connection} dicts.

def info_languages(info: dict) -> set:
537def info_languages(info: dict) -> set:
538    """The language codes of the translated texts of a project (title, informations, questions)."""
539    langs = set()
540    for key in ('title', 'informations'):
541        v = info.get(key)
542        if isinstance(v, dict):
543            langs.update(v.keys())
544    for q in info.get('questions', []) or []:
545        for field_name in ('title', 'description'):
546            v = q.get(field_name)
547            if isinstance(v, dict):
548                langs.update(v.keys())
549    return langs

The language codes of the translated texts of a project (title, informations, questions).

def project_title(info: dict, lang_priority: list, fallback: str = '') -> str:
552def project_title(info: dict, lang_priority: list, fallback: str = '') -> str:
553    """The title of a project tab: ``title`` of info.json in the user's language, else the lab
554    name without ``.py``, else *fallback* (the running lab name)."""
555    raw = info.get("title", info.get("lab_name", fallback).removesuffix('.py'))
556    return TranslatedText.from_value(raw).resolve_priority(lang_priority) or str(raw)

The title of a project tab: title of info.json in the user's language, else the lab name without .py, else fallback (the running lab name).

def title_sort_key(text: str) -> tuple:
559def title_sort_key(text: str) -> tuple:
560    """The key of the alphabetical order of the labels of the lab tree (``"@sort": "title"`` in
561    a ``titles.json``): accents and case ignored, the runs of digits compared as numbers ("TP 2"
562    before "TP 10"). The even positions of the tuple hold text, the odd ones integers, so two
563    keys always compare."""
564    decomposed = unicodedata.normalize('NFKD', text or '')
565    plain = ''.join(c for c in decomposed if not unicodedata.combining(c)).casefold().strip()
566    return tuple(int(part) if i % 2 else part for i, part in enumerate(re.split(r'(\d+)', plain)))

The key of the alphabetical order of the labels of the lab tree ("@sort": "title" in a titles.json): accents and case ignored, the runs of digits compared as numbers ("TP 2" before "TP 10"). The even positions of the tuple hold text, the odd ones integers, so two keys always compare.

def order_tree_entries(children: list, mode: str) -> list:
569def order_tree_entries(children: list, mode: str) -> list:
570    """The children of one directory of the lab tree in display order: the sub-directories first,
571    then the labs; within each group, by label in the ``title`` mode (``title_sort_key``, then
572    ``name``), else in arrival order (``index``: ``sre list --with-titles`` already gives the
573    ``name`` and ``manual`` orders, which the consumers must not sort again). Each child is a
574    dict with ``kind`` (``params.lab_list_kind_dir`` / ``_lab``), ``name``, ``label`` and
575    ``index``; the dicts come back as they are."""
576    def key(child):
577        group = 0 if child.get('kind') == params.lab_list_kind_dir else 1
578        if mode == params.titles_sort_title:
579            return (group, title_sort_key(child.get('label') or ''), child.get('name') or '')
580        return (group, child.get('index', 0))
581    return sorted(children, key=key)

The children of one directory of the lab tree in display order: the sub-directories first, then the labs; within each group, by label in the title mode (title_sort_key, then name), else in arrival order (index: sre list --with-titles already gives the name and manual orders, which the consumers must not sort again). Each child is a dict with kind (params.lab_list_kind_dir / _lab), name, label and index; the dicts come back as they are.

def listed_states( user_allowed_states: dict, admin_only_states, *, show_admin_only: bool) -> dict:
584def listed_states(user_allowed_states: dict, admin_only_states, *, show_admin_only: bool) -> dict:
585    """The states of the Apply Configuration tab: every state of ``user_allowed_states``, or only
586    those a student could apply when *show_admin_only* is false (project in instructor mode while
587    the "Instructor mode" button is off)."""
588    if show_admin_only:
589        return dict(user_allowed_states or {})
590    admin_only = set(admin_only_states or [])
591    return {state: description for state, description in (user_allowed_states or {}).items()
592            if state not in admin_only}

The states of the Apply Configuration tab: every state of user_allowed_states, or only those a student could apply when show_admin_only is false (project in instructor mode while the "Instructor mode" button is off).

def state_rows( user_allowed_states: dict, admin_only_states, *, show_admin_only: bool, lang_priority: list) -> list:
595def state_rows(user_allowed_states: dict, admin_only_states, *, show_admin_only: bool,
596               lang_priority: list) -> list:
597    """The rows of the Apply Configuration tab: ``(state, label, admin_only)`` tuples, the label
598    being the description of the state in the user's language, else the state name."""
599    admin_only = set(admin_only_states or [])
600    rows = []
601    for state, description in listed_states(user_allowed_states, admin_only_states,
602                                            show_admin_only=show_admin_only).items():
603        resolved = (TranslatedText.from_value(description).resolve_priority(lang_priority)
604                    if description else '')
605        rows.append((state, resolved if resolved else state, state in admin_only))
606    return rows

The rows of the Apply Configuration tab: (state, label, admin_only) tuples, the label being the description of the state in the user's language, else the state name.

def visible_tabs( info: dict, *, exam_mode: bool, instructor_shown: bool, display: bool = False, evaluation: bool | None = None) -> list:
609def visible_tabs(info: dict, *, exam_mode: bool, instructor_shown: bool, display: bool = False,
610                 evaluation: bool | None = None) -> list:
611    """The tabs shown for a project, in display order (the ``TAB_*`` identifiers).
612
613    *instructor_shown*: the project has instructor texts (instructor mode or debug project) and
614    the "Instructor mode" button is on.  A debug project always gets the Full Schema, every state
615    and the Log; the Evaluation tab is hidden in exam mode and when the lab does not allow
616    self-evaluation (*evaluation* forces it on or off whatever the lab says: the web shows an
617    instructor the last archived evaluation of any project); the Apply Configuration tab needs
618    listed states (and not an exam, unless the instructor view is on).  *display*: the web only,
619    the Display tab of the virtual X desktop (after the Terminals); the Qt GUI never asks for it."""
620    debug = bool(info.get("debug_project", False))
621    if evaluation is None:
622        evaluation = bool(info.get("allow_self_grade", True))
623    tabs = [TAB_SCHEMA]
624    if debug:
625        tabs.append(TAB_FULL_SCHEMA)
626    tabs += [TAB_INFORMATIONS, TAB_MACHINES, TAB_NETWORKS]
627    if info.get("questions"):
628        tabs.append(TAB_QUESTIONS)
629    if not exam_mode and evaluation:
630        tabs.append(TAB_EVALUATION)
631    tabs.append(TAB_TERMINALS)
632    if display:
633        tabs.append(TAB_DISPLAY)
634    states = listed_states(info.get("user_allowed_states", {}), info.get("admin_only_states", []),
635                           show_admin_only=debug or instructor_shown)
636    if debug or ((instructor_shown or not exam_mode) and bool(states)):
637        tabs.append(TAB_APPLY_CONFIG)
638    if debug or instructor_shown:
639        tabs.append(TAB_LOG)
640    return tabs

The tabs shown for a project, in display order (the TAB_* identifiers).

instructor_shown: the project has instructor texts (instructor mode or debug project) and the "Instructor mode" button is on. A debug project always gets the Full Schema, every state and the Log; the Evaluation tab is hidden in exam mode and when the lab does not allow self-evaluation (evaluation forces it on or off whatever the lab says: the web shows an instructor the last archived evaluation of any project); the Apply Configuration tab needs listed states (and not an exam, unless the instructor view is on). display: the web only, the Display tab of the virtual X desktop (after the Terminals); the Qt GUI never asks for it.

def project_accessible(running_lab_name: str, username: str | None) -> bool:
647def project_accessible(running_lab_name: str, username: str | None) -> bool:
648    """Whether *username*, in user mode, may see and act on the project *running_lab_name*
649    (:func:`SRE.access.may_access_project` with ``params.check_project_owner``: their own
650    projects and those of root or the sre user, every project when the check is off).
651    Privileged callers are never asked: the CLI calls this in user mode only, the GUI always
652    (it goes through sre-wrapper)."""
653    return access.may_access_project(params.get_username_from_running_lab_name(running_lab_name),
654                                     username or '', check_owner=params.check_project_owner,
655                                     shared_owners=('root', params.sre_user))

Whether username, in user mode, may see and act on the project running_lab_name (SRE.access.may_access_project() with params.check_project_owner: their own projects and those of root or the sre user, every project when the check is off). Privileged callers are never asked: the CLI calls this in user mode only, the GUI always (it goes through sre-wrapper).