SRE.common

  1import hashlib
  2import json
  3from dataclasses import dataclass, asdict, field
  4from typing import List
  5
  6import msgpack
  7from enum import Enum
  8
  9
 10class TranslatedText(dict):
 11    """A dict subclass holding translations keyed by language code (e.g. 'en', 'fr').
 12
 13    Use ``resolve(lang)`` to get the text for a specific language, falling back to the
 14    first available translation.  ``from_value`` ensures backward-compat with plain strings.
 15    """
 16
 17    def resolve(self, lang: str, fallback: str = '') -> str:
 18        return self.get(lang) or next(iter(self.values()), fallback)
 19
 20    def resolve_priority(self, langs: list, fallback: str = '') -> str:
 21        """Return the first translation found in the priority list, or first available."""
 22        for lang in langs:
 23            if self.get(lang):
 24                return self[lang]
 25        return next(iter(self.values()), fallback)
 26
 27    def __add__(self, other) -> 'TranslatedText':
 28        if isinstance(other, str):
 29            return TranslatedText({lang: self[lang] + other for lang in self})
 30        if not isinstance(other, TranslatedText):
 31            return NotImplemented
 32        if not self:
 33            return TranslatedText(other)
 34        if not other:
 35            return TranslatedText(self)
 36        # Merge mismatched language sets gracefully (partial translations): take
 37        # the union of languages, falling back to each side's default-language
 38        # (first-key) value for a language that side lacks.
 39        self_default = next(iter(self.values()))
 40        other_default = next(iter(other.values()))
 41        langs = list(dict.fromkeys((*self, *other)))
 42        return TranslatedText({
 43            lang: self.get(lang, self_default) + other.get(lang, other_default)
 44            for lang in langs
 45        })
 46
 47    def __radd__(self, other) -> 'TranslatedText':
 48        if isinstance(other, str):
 49            return TranslatedText({lang: other + self[lang] for lang in self})
 50        return NotImplemented
 51
 52    def format(self, **kwargs) -> 'TranslatedText':
 53        """Apply str.format(**kwargs) to every language value."""
 54        return TranslatedText({lang: text.format(**kwargs) for lang, text in self.items()})
 55
 56    @classmethod
 57    def from_value(cls, v, default_lang: str = 'en') -> 'TranslatedText':
 58        if isinstance(v, cls):
 59            return v
 60        if isinstance(v, dict):
 61            return cls(v)
 62        return cls({default_lang: str(v)}) if v else cls()
 63
 64
 65def _tt_hash_str(v) -> str:
 66    """Stable string representation of a TranslatedText (or plain str) for hashing."""
 67    return json.dumps(dict(sorted(TranslatedText.from_value(v).items())), ensure_ascii=False)
 68
 69
 70class QuestionType(Enum):
 71    DUMMY = 0
 72    TEXT = 1
 73    FORM = 2
 74
 75
 76class Question:
 77    """Abstract base for all question types (TEXT, FORM, DUMMY)."""
 78
 79    def __init__(self, title, description, question_hash, order, question_type):
 80        self.title = title
 81        self.description = description
 82        self.question_hash = question_hash
 83        self.order = order
 84        self.question_type = question_type
 85
 86
 87
 88@dataclass
 89class QuestionText(Question):
 90    """A free-text answer question.  Hash is derived from *title* if not provided."""
 91
 92    title: TranslatedText | str
 93    description: TranslatedText | str
 94    question_hash: str
 95    order: int
 96    question_type: int = QuestionType.TEXT.value
 97
 98    def __init__(self, title, description='', question_hash=None, order=None, **kwargs):
 99        super().__init__(title, description, question_hash, order, question_type=QuestionType.TEXT)
100        if question_hash is None:
101            self.question_hash = hashlib.sha256(_tt_hash_str(title).encode('UTF-8')).hexdigest()
102        else:
103            self.question_hash = question_hash
104        self.question_type = QuestionType.TEXT.value
105
106    def to_json(self) -> str:
107        return json.dumps(asdict(self))
108
109    @classmethod
110    def from_json(cls, json_data: str) -> "QuestionText":
111        d = json.loads(json_data)
112        if isinstance(d.get('title'), dict):
113            d['title'] = TranslatedText(d['title'])
114        if isinstance(d.get('description'), dict):
115            d['description'] = TranslatedText(d['description'])
116        return cls(**d)
117
118
119@dataclass
120class QuestionDummy(Question):
121    """A display-only block (no answer input).  Hash derived from title + description."""
122
123    title: TranslatedText | str
124    description: TranslatedText | str
125    question_hash: str
126    order: int
127    question_type: int = QuestionType.DUMMY.value
128
129    def __init__(self, title, description, question_hash=None, order=None, **kwargs):
130        super().__init__(title, description, question_hash, order, question_type=QuestionType.DUMMY)
131        if question_hash is None:
132            self.question_hash = hashlib.sha256(
133                (_tt_hash_str(title) + '--' + _tt_hash_str(description)).encode('UTF-8')
134            ).hexdigest()
135        else:
136            self.question_hash = question_hash
137        self.question_type = QuestionType.DUMMY.value
138
139    def to_json(self) -> str:
140        return json.dumps(asdict(self))
141
142    @classmethod
143    def from_json(cls, json_data: str) -> "QuestionDummy":
144        d = json.loads(json_data)
145        if isinstance(d.get('title'), dict):
146            d['title'] = TranslatedText(d['title'])
147        if isinstance(d.get('description'), dict):
148            d['description'] = TranslatedText(d['description'])
149        return cls(**d)
150
151
152@dataclass
153class QuestionForm(Question):
154    """A structured-form question with ``@@{field:regex}@@`` inline markers.
155
156    ``fields`` is a list of dicts, each either ``{"name": str, "regex": str}``
157    for a text input or ``{"name": str, "choices": [...]}`` for a dropdown.
158    """
159
160    title: TranslatedText | str
161    description: TranslatedText | str
162    question_hash: str
163    order: int
164    fields: list  # [{"name": str, "regex": str}, ...]
165    question_type: int = QuestionType.FORM.value
166
167    def __init__(self, title, description, question_hash=None, order=None, fields=None, **kwargs):
168        super().__init__(title, description, question_hash, order, question_type=QuestionType.FORM)
169        if question_hash is None:
170            self.question_hash = hashlib.sha256(
171                (_tt_hash_str(title) + '--' + _tt_hash_str(description)).encode('UTF-8')
172            ).hexdigest()
173        else:
174            self.question_hash = question_hash
175        self.fields = fields or []
176        self.question_type = QuestionType.FORM.value
177
178    def to_json(self) -> str:
179        return json.dumps(asdict(self))
180
181    @classmethod
182    def from_json(cls, json_data: str) -> "QuestionForm":
183        d = json.loads(json_data)
184        if isinstance(d.get('title'), dict):
185            d['title'] = TranslatedText(d['title'])
186        if isinstance(d.get('description'), dict):
187            d['description'] = TranslatedText(d['description'])
188        return cls(**d)
189
190
191@dataclass
192class GradeElement:
193    """One entry in a grade rubric.
194
195    Either numeric (``grade`` / ``max_grade``) or letter-graded (``grade_letter`` ∈ OK/MEH/FAIL).
196    Call ``to_grade_letter()`` to convert a numeric element to its letter equivalent.
197
198    ``scope`` is a bitmask: bit 0 (SELF_EVAL_SCOPE=1) makes the element visible in
199    user-triggered self-eval (``sre eval --auto-eval``); bit 1 (EXO_EVAL_SCOPE=2)
200    makes it visible in non-user-triggered eval, ``sre outline`` and ``sre sheet``.
201    Default 3 (BOTH_EVAL_SCOPE) means visible in both.
202    """
203
204    title: str
205    max_grade: float | None = None
206    grade: float | None = None
207    grade_letter: str | None = None
208    description: TranslatedText | str = ""
209    scope: int = 3
210    grade_part: str | None = None
211
212    def to_dict(self) -> dict:
213        return asdict(self)
214
215    @classmethod
216    def from_dict(cls, d: dict) -> "GradeElement":
217        d = dict(d)
218        if isinstance(d.get('description'), dict):
219            d['description'] = TranslatedText(d['description'])
220        return cls(**d)
221
222    def pack(self) -> bytes:
223        return msgpack.packb(self.to_dict(), use_bin_type=True)
224
225    @classmethod
226    def unpack(cls, data: bytes) -> "GradeElement":
227        return cls.from_dict(msgpack.unpackb(data, raw=False))
228
229    def to_json(self) -> str:
230        return json.dumps(asdict(self))
231
232    @classmethod
233    def from_json(cls, s: str) -> "GradeElement":
234        d = json.loads(s)
235        if isinstance(d.get('description'), dict):
236            d['description'] = TranslatedText(d['description'])
237        return cls(**d)
238
239    def to_grade_letter(self) -> "GradeElement":
240        if self.grade == self.max_grade:
241            letter = "OK"
242        elif self.grade is not None and self.grade != 0:
243            letter = "MEH"
244        else:
245            letter = "FAIL"
246        return GradeElement(title=self.title, max_grade=None, grade=None, grade_letter=letter,
247                            description=self.description, scope=self.scope,
248                            grade_part=self.grade_part)
249
250
251@dataclass
252class GradePart:
253    """A named group of GradeElements with a shared title and description.
254
255    Lab code registers parts via ``Grade.add_grade_part()`` and associates
256    each :class:`GradeElement` with one via the ``grade_part=`` kwarg on
257    ``add_grade_element``.  The element stores the part's *title* (not the
258    object) so it serializes cleanly to JSON / msgpack.
259    """
260
261    title: str
262    description: TranslatedText | str = ""
263
264    def to_dict(self) -> dict:
265        return asdict(self)
266
267    @classmethod
268    def from_dict(cls, d: dict) -> "GradePart":
269        d = dict(d)
270        if isinstance(d.get('description'), dict):
271            d['description'] = TranslatedText(d['description'])
272        return cls(**d)
273
274    def pack(self) -> bytes:
275        return msgpack.packb(self.to_dict(), use_bin_type=True)
276
277    @classmethod
278    def unpack(cls, data: bytes) -> "GradePart":
279        return cls.from_dict(msgpack.unpackb(data, raw=False))
280
281    def to_json(self) -> str:
282        return json.dumps(asdict(self))
283
284    @classmethod
285    def from_json(cls, s: str) -> "GradePart":
286        d = json.loads(s)
287        if isinstance(d.get('description'), dict):
288            d['description'] = TranslatedText(d['description'])
289        return cls(**d)
290
291
292@dataclass
293class InfoInterface:
294    """Network interface descriptor stored inside ``InfoMachine``."""
295
296    network: str
297    interface_name: str
298
299    def to_json(self) -> str:
300        return json.dumps(asdict(self))
301
302    @classmethod
303    def from_json(cls, json_data: str) -> "InfoInterface":
304        return cls(**json.loads(json_data))
305
306
307@dataclass
308class InfoMachine:
309    """Runtime snapshot of a single container, written to ``info.json`` by the CLI and read by the GUI."""
310
311    name: str
312    status: str
313    allow_connection: bool
314    hidden: bool
315    interfaces: List[InfoInterface]
316    ports: List[str]
317    bridged: bool
318    x11_host: bool = False
319    color: str = ""
320    shape: str = ""
321
322    def to_json(self):
323        return json.dumps(asdict(self))
324
325    @classmethod
326    def from_json(cls, json_data: str) -> "InfoMachine":
327        return cls(**json.loads(json_data))
328
329
330@dataclass
331class InfoLab:
332    """Full lab metadata written to ``info.json`` at ``sre start`` and polled by the GUI every second.
333
334    ``informations`` (not ``description``) holds the Markdown lab description.
335    ``questions`` is a list of ``QuestionText | QuestionDummy | QuestionForm`` instances.
336    """
337
338    lab_name: str
339    lab_hash: str
340    title: TranslatedText
341    informations: TranslatedText
342    export_kathara_project: bool
343    allow_self_grade: bool
344    machines: List[InfoMachine]
345    questions: List[Question]
346    delay_between_self_grade: int
347    eval_interval_without_exam_mode: int
348    eval_before_exit: bool
349    user_allowed_states: dict
350    debug_project: bool = False
351    admin_only_states: list = field(default_factory=list)
352    default_language: str = ''
353    network_colors: dict = field(default_factory=dict)
354    network_shapes: dict = field(default_factory=dict)
355    show_nat_network: bool = False
356    nat_network_name: str = ''
357    nat_network_color: str = ''
358    host_network_exploded: bool = False
359    host_network_edge_relative_length: float = 1.0
360    schema_splines: str = 'curved'
361    schema_overlap: str = 'prism'
362
363    def to_json(self) -> str:
364        return json.dumps(asdict(self), indent=4)
365
366    @classmethod
367    def from_json(cls, s: str) -> "InfoLab":
368        d = json.loads(s)
369
370        def _wrap_q(item: dict) -> dict:
371            item = dict(item)
372            item['title'] = TranslatedText.from_value(item.get('title', ''))
373            item['description'] = TranslatedText.from_value(item.get('description', ''))
374            return item
375
376        return cls(
377            lab_name=d["lab_name"],
378            lab_hash=d["lab_hash"],
379            title=TranslatedText.from_value(d["title"]),
380            informations=TranslatedText.from_value(d.get("informations", "")),
381            export_kathara_project=d.get("export_kathara_project", True),
382            allow_self_grade=d.get("allow_self_grade", False),
383            debug_project=d.get("debug_project", False),
384            delay_between_self_grade=d.get("delay_between_self_grade", 0),
385            eval_interval_without_exam_mode=d.get("eval_interval_without_exam_mode", 0),
386            eval_before_exit=d.get("eval_before_exit", False),
387            user_allowed_states=d.get("user_allowed_states", {}),
388            admin_only_states=list(d.get("admin_only_states", []) or []),
389            default_language=d.get("default_language", ''),
390            network_colors=d.get("network_colors", {}),
391            network_shapes=d.get("network_shapes", {}),
392            show_nat_network=d.get("show_nat_network", False),
393            nat_network_name=d.get("nat_network_name", ''),
394            nat_network_color=d.get("nat_network_color", ''),
395            host_network_exploded=d.get("host_network_exploded", False),
396            host_network_edge_relative_length=float(d.get("host_network_edge_relative_length", 1.0)),
397            schema_splines=d.get("schema_splines", "curved"),
398            schema_overlap=d.get("schema_overlap", "prism"),
399            machines=[InfoMachine(**item) for item in d["machines"]],
400            questions=[
401                QuestionDummy(**_wrap_q(item)) if item.get("question_type") == QuestionType.DUMMY.value
402                else QuestionForm(**_wrap_q(item)) if item.get("question_type") == QuestionType.FORM.value
403                else QuestionText(**_wrap_q(item))
404                for item in d["questions"]
405            ],
406        )
class TranslatedText(builtins.dict):
11class TranslatedText(dict):
12    """A dict subclass holding translations keyed by language code (e.g. 'en', 'fr').
13
14    Use ``resolve(lang)`` to get the text for a specific language, falling back to the
15    first available translation.  ``from_value`` ensures backward-compat with plain strings.
16    """
17
18    def resolve(self, lang: str, fallback: str = '') -> str:
19        return self.get(lang) or next(iter(self.values()), fallback)
20
21    def resolve_priority(self, langs: list, fallback: str = '') -> str:
22        """Return the first translation found in the priority list, or first available."""
23        for lang in langs:
24            if self.get(lang):
25                return self[lang]
26        return next(iter(self.values()), fallback)
27
28    def __add__(self, other) -> 'TranslatedText':
29        if isinstance(other, str):
30            return TranslatedText({lang: self[lang] + other for lang in self})
31        if not isinstance(other, TranslatedText):
32            return NotImplemented
33        if not self:
34            return TranslatedText(other)
35        if not other:
36            return TranslatedText(self)
37        # Merge mismatched language sets gracefully (partial translations): take
38        # the union of languages, falling back to each side's default-language
39        # (first-key) value for a language that side lacks.
40        self_default = next(iter(self.values()))
41        other_default = next(iter(other.values()))
42        langs = list(dict.fromkeys((*self, *other)))
43        return TranslatedText({
44            lang: self.get(lang, self_default) + other.get(lang, other_default)
45            for lang in langs
46        })
47
48    def __radd__(self, other) -> 'TranslatedText':
49        if isinstance(other, str):
50            return TranslatedText({lang: other + self[lang] for lang in self})
51        return NotImplemented
52
53    def format(self, **kwargs) -> 'TranslatedText':
54        """Apply str.format(**kwargs) to every language value."""
55        return TranslatedText({lang: text.format(**kwargs) for lang, text in self.items()})
56
57    @classmethod
58    def from_value(cls, v, default_lang: str = 'en') -> 'TranslatedText':
59        if isinstance(v, cls):
60            return v
61        if isinstance(v, dict):
62            return cls(v)
63        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:
18    def resolve(self, lang: str, fallback: str = '') -> str:
19        return self.get(lang) or next(iter(self.values()), fallback)
def resolve_priority(self, langs: list, fallback: str = '') -> str:
21    def resolve_priority(self, langs: list, fallback: str = '') -> str:
22        """Return the first translation found in the priority list, or first available."""
23        for lang in langs:
24            if self.get(lang):
25                return self[lang]
26        return next(iter(self.values()), fallback)

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

def format(self, **kwargs) -> TranslatedText:
53    def format(self, **kwargs) -> 'TranslatedText':
54        """Apply str.format(**kwargs) to every language value."""
55        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:
57    @classmethod
58    def from_value(cls, v, default_lang: str = 'en') -> 'TranslatedText':
59        if isinstance(v, cls):
60            return v
61        if isinstance(v, dict):
62            return cls(v)
63        return cls({default_lang: str(v)}) if v else cls()
class QuestionType(enum.Enum):
71class QuestionType(Enum):
72    DUMMY = 0
73    TEXT = 1
74    FORM = 2
DUMMY = <QuestionType.DUMMY: 0>
TEXT = <QuestionType.TEXT: 1>
FORM = <QuestionType.FORM: 2>
class Question:
77class Question:
78    """Abstract base for all question types (TEXT, FORM, DUMMY)."""
79
80    def __init__(self, title, description, question_hash, order, question_type):
81        self.title = title
82        self.description = description
83        self.question_hash = question_hash
84        self.order = order
85        self.question_type = question_type

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

Question(title, description, question_hash, order, question_type)
80    def __init__(self, title, description, question_hash, order, question_type):
81        self.title = title
82        self.description = description
83        self.question_hash = question_hash
84        self.order = order
85        self.question_type = question_type
title
description
question_hash
order
question_type
@dataclass
class QuestionText(Question):
 89@dataclass
 90class QuestionText(Question):
 91    """A free-text answer question.  Hash is derived from *title* if not provided."""
 92
 93    title: TranslatedText | str
 94    description: TranslatedText | str
 95    question_hash: str
 96    order: int
 97    question_type: int = QuestionType.TEXT.value
 98
 99    def __init__(self, title, description='', question_hash=None, order=None, **kwargs):
100        super().__init__(title, description, question_hash, order, question_type=QuestionType.TEXT)
101        if question_hash is None:
102            self.question_hash = hashlib.sha256(_tt_hash_str(title).encode('UTF-8')).hexdigest()
103        else:
104            self.question_hash = question_hash
105        self.question_type = QuestionType.TEXT.value
106
107    def to_json(self) -> str:
108        return json.dumps(asdict(self))
109
110    @classmethod
111    def from_json(cls, json_data: str) -> "QuestionText":
112        d = json.loads(json_data)
113        if isinstance(d.get('title'), dict):
114            d['title'] = TranslatedText(d['title'])
115        if isinstance(d.get('description'), dict):
116            d['description'] = TranslatedText(d['description'])
117        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)
 99    def __init__(self, title, description='', question_hash=None, order=None, **kwargs):
100        super().__init__(title, description, question_hash, order, question_type=QuestionType.TEXT)
101        if question_hash is None:
102            self.question_hash = hashlib.sha256(_tt_hash_str(title).encode('UTF-8')).hexdigest()
103        else:
104            self.question_hash = question_hash
105        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:
107    def to_json(self) -> str:
108        return json.dumps(asdict(self))
@classmethod
def from_json(cls, json_data: str) -> QuestionText:
110    @classmethod
111    def from_json(cls, json_data: str) -> "QuestionText":
112        d = json.loads(json_data)
113        if isinstance(d.get('title'), dict):
114            d['title'] = TranslatedText(d['title'])
115        if isinstance(d.get('description'), dict):
116            d['description'] = TranslatedText(d['description'])
117        return cls(**d)
@dataclass
class QuestionDummy(Question):
120@dataclass
121class QuestionDummy(Question):
122    """A display-only block (no answer input).  Hash derived from title + description."""
123
124    title: TranslatedText | str
125    description: TranslatedText | str
126    question_hash: str
127    order: int
128    question_type: int = QuestionType.DUMMY.value
129
130    def __init__(self, title, description, question_hash=None, order=None, **kwargs):
131        super().__init__(title, description, question_hash, order, question_type=QuestionType.DUMMY)
132        if question_hash is None:
133            self.question_hash = hashlib.sha256(
134                (_tt_hash_str(title) + '--' + _tt_hash_str(description)).encode('UTF-8')
135            ).hexdigest()
136        else:
137            self.question_hash = question_hash
138        self.question_type = QuestionType.DUMMY.value
139
140    def to_json(self) -> str:
141        return json.dumps(asdict(self))
142
143    @classmethod
144    def from_json(cls, json_data: str) -> "QuestionDummy":
145        d = json.loads(json_data)
146        if isinstance(d.get('title'), dict):
147            d['title'] = TranslatedText(d['title'])
148        if isinstance(d.get('description'), dict):
149            d['description'] = TranslatedText(d['description'])
150        return cls(**d)

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

QuestionDummy(title, description, question_hash=None, order=None, **kwargs)
130    def __init__(self, title, description, question_hash=None, order=None, **kwargs):
131        super().__init__(title, description, question_hash, order, question_type=QuestionType.DUMMY)
132        if question_hash is None:
133            self.question_hash = hashlib.sha256(
134                (_tt_hash_str(title) + '--' + _tt_hash_str(description)).encode('UTF-8')
135            ).hexdigest()
136        else:
137            self.question_hash = question_hash
138        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:
140    def to_json(self) -> str:
141        return json.dumps(asdict(self))
@classmethod
def from_json(cls, json_data: str) -> QuestionDummy:
143    @classmethod
144    def from_json(cls, json_data: str) -> "QuestionDummy":
145        d = json.loads(json_data)
146        if isinstance(d.get('title'), dict):
147            d['title'] = TranslatedText(d['title'])
148        if isinstance(d.get('description'), dict):
149            d['description'] = TranslatedText(d['description'])
150        return cls(**d)
@dataclass
class QuestionForm(Question):
153@dataclass
154class QuestionForm(Question):
155    """A structured-form question with ``@@{field:regex}@@`` inline markers.
156
157    ``fields`` is a list of dicts, each either ``{"name": str, "regex": str}``
158    for a text input or ``{"name": str, "choices": [...]}`` for a dropdown.
159    """
160
161    title: TranslatedText | str
162    description: TranslatedText | str
163    question_hash: str
164    order: int
165    fields: list  # [{"name": str, "regex": str}, ...]
166    question_type: int = QuestionType.FORM.value
167
168    def __init__(self, title, description, question_hash=None, order=None, fields=None, **kwargs):
169        super().__init__(title, description, question_hash, order, question_type=QuestionType.FORM)
170        if question_hash is None:
171            self.question_hash = hashlib.sha256(
172                (_tt_hash_str(title) + '--' + _tt_hash_str(description)).encode('UTF-8')
173            ).hexdigest()
174        else:
175            self.question_hash = question_hash
176        self.fields = fields or []
177        self.question_type = QuestionType.FORM.value
178
179    def to_json(self) -> str:
180        return json.dumps(asdict(self))
181
182    @classmethod
183    def from_json(cls, json_data: str) -> "QuestionForm":
184        d = json.loads(json_data)
185        if isinstance(d.get('title'), dict):
186            d['title'] = TranslatedText(d['title'])
187        if isinstance(d.get('description'), dict):
188            d['description'] = TranslatedText(d['description'])
189        return cls(**d)

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

fields is a list of dicts, each either {"name": str, "regex": str} for a text input or {"name": str, "choices": [...]} for a dropdown.

QuestionForm( title, description, question_hash=None, order=None, fields=None, **kwargs)
168    def __init__(self, title, description, question_hash=None, order=None, fields=None, **kwargs):
169        super().__init__(title, description, question_hash, order, question_type=QuestionType.FORM)
170        if question_hash is None:
171            self.question_hash = hashlib.sha256(
172                (_tt_hash_str(title) + '--' + _tt_hash_str(description)).encode('UTF-8')
173            ).hexdigest()
174        else:
175            self.question_hash = question_hash
176        self.fields = fields or []
177        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:
179    def to_json(self) -> str:
180        return json.dumps(asdict(self))
@classmethod
def from_json(cls, json_data: str) -> QuestionForm:
182    @classmethod
183    def from_json(cls, json_data: str) -> "QuestionForm":
184        d = json.loads(json_data)
185        if isinstance(d.get('title'), dict):
186            d['title'] = TranslatedText(d['title'])
187        if isinstance(d.get('description'), dict):
188            d['description'] = TranslatedText(d['description'])
189        return cls(**d)
@dataclass
class GradeElement:
192@dataclass
193class GradeElement:
194    """One entry in a grade rubric.
195
196    Either numeric (``grade`` / ``max_grade``) or letter-graded (``grade_letter`` ∈ OK/MEH/FAIL).
197    Call ``to_grade_letter()`` to convert a numeric element to its letter equivalent.
198
199    ``scope`` is a bitmask: bit 0 (SELF_EVAL_SCOPE=1) makes the element visible in
200    user-triggered self-eval (``sre eval --auto-eval``); bit 1 (EXO_EVAL_SCOPE=2)
201    makes it visible in non-user-triggered eval, ``sre outline`` and ``sre sheet``.
202    Default 3 (BOTH_EVAL_SCOPE) means visible in both.
203    """
204
205    title: str
206    max_grade: float | None = None
207    grade: float | None = None
208    grade_letter: str | None = None
209    description: TranslatedText | str = ""
210    scope: int = 3
211    grade_part: str | None = None
212
213    def to_dict(self) -> dict:
214        return asdict(self)
215
216    @classmethod
217    def from_dict(cls, d: dict) -> "GradeElement":
218        d = dict(d)
219        if isinstance(d.get('description'), dict):
220            d['description'] = TranslatedText(d['description'])
221        return cls(**d)
222
223    def pack(self) -> bytes:
224        return msgpack.packb(self.to_dict(), use_bin_type=True)
225
226    @classmethod
227    def unpack(cls, data: bytes) -> "GradeElement":
228        return cls.from_dict(msgpack.unpackb(data, raw=False))
229
230    def to_json(self) -> str:
231        return json.dumps(asdict(self))
232
233    @classmethod
234    def from_json(cls, s: str) -> "GradeElement":
235        d = json.loads(s)
236        if isinstance(d.get('description'), dict):
237            d['description'] = TranslatedText(d['description'])
238        return cls(**d)
239
240    def to_grade_letter(self) -> "GradeElement":
241        if self.grade == self.max_grade:
242            letter = "OK"
243        elif self.grade is not None and self.grade != 0:
244            letter = "MEH"
245        else:
246            letter = "FAIL"
247        return GradeElement(title=self.title, max_grade=None, grade=None, grade_letter=letter,
248                            description=self.description, scope=self.scope,
249                            grade_part=self.grade_part)

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)
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
def to_dict(self) -> dict:
213    def to_dict(self) -> dict:
214        return asdict(self)
@classmethod
def from_dict(cls, d: dict) -> GradeElement:
216    @classmethod
217    def from_dict(cls, d: dict) -> "GradeElement":
218        d = dict(d)
219        if isinstance(d.get('description'), dict):
220            d['description'] = TranslatedText(d['description'])
221        return cls(**d)
def pack(self) -> bytes:
223    def pack(self) -> bytes:
224        return msgpack.packb(self.to_dict(), use_bin_type=True)
@classmethod
def unpack(cls, data: bytes) -> GradeElement:
226    @classmethod
227    def unpack(cls, data: bytes) -> "GradeElement":
228        return cls.from_dict(msgpack.unpackb(data, raw=False))
def to_json(self) -> str:
230    def to_json(self) -> str:
231        return json.dumps(asdict(self))
@classmethod
def from_json(cls, s: str) -> GradeElement:
233    @classmethod
234    def from_json(cls, s: str) -> "GradeElement":
235        d = json.loads(s)
236        if isinstance(d.get('description'), dict):
237            d['description'] = TranslatedText(d['description'])
238        return cls(**d)
def to_grade_letter(self) -> GradeElement:
240    def to_grade_letter(self) -> "GradeElement":
241        if self.grade == self.max_grade:
242            letter = "OK"
243        elif self.grade is not None and self.grade != 0:
244            letter = "MEH"
245        else:
246            letter = "FAIL"
247        return GradeElement(title=self.title, max_grade=None, grade=None, grade_letter=letter,
248                            description=self.description, scope=self.scope,
249                            grade_part=self.grade_part)
@dataclass
class GradePart:
252@dataclass
253class GradePart:
254    """A named group of GradeElements with a shared title and description.
255
256    Lab code registers parts via ``Grade.add_grade_part()`` and associates
257    each :class:`GradeElement` with one via the ``grade_part=`` kwarg on
258    ``add_grade_element``.  The element stores the part's *title* (not the
259    object) so it serializes cleanly to JSON / msgpack.
260    """
261
262    title: str
263    description: TranslatedText | str = ""
264
265    def to_dict(self) -> dict:
266        return asdict(self)
267
268    @classmethod
269    def from_dict(cls, d: dict) -> "GradePart":
270        d = dict(d)
271        if isinstance(d.get('description'), dict):
272            d['description'] = TranslatedText(d['description'])
273        return cls(**d)
274
275    def pack(self) -> bytes:
276        return msgpack.packb(self.to_dict(), use_bin_type=True)
277
278    @classmethod
279    def unpack(cls, data: bytes) -> "GradePart":
280        return cls.from_dict(msgpack.unpackb(data, raw=False))
281
282    def to_json(self) -> str:
283        return json.dumps(asdict(self))
284
285    @classmethod
286    def from_json(cls, s: str) -> "GradePart":
287        d = json.loads(s)
288        if isinstance(d.get('description'), dict):
289            d['description'] = TranslatedText(d['description'])
290        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:
265    def to_dict(self) -> dict:
266        return asdict(self)
@classmethod
def from_dict(cls, d: dict) -> GradePart:
268    @classmethod
269    def from_dict(cls, d: dict) -> "GradePart":
270        d = dict(d)
271        if isinstance(d.get('description'), dict):
272            d['description'] = TranslatedText(d['description'])
273        return cls(**d)
def pack(self) -> bytes:
275    def pack(self) -> bytes:
276        return msgpack.packb(self.to_dict(), use_bin_type=True)
@classmethod
def unpack(cls, data: bytes) -> GradePart:
278    @classmethod
279    def unpack(cls, data: bytes) -> "GradePart":
280        return cls.from_dict(msgpack.unpackb(data, raw=False))
def to_json(self) -> str:
282    def to_json(self) -> str:
283        return json.dumps(asdict(self))
@classmethod
def from_json(cls, s: str) -> GradePart:
285    @classmethod
286    def from_json(cls, s: str) -> "GradePart":
287        d = json.loads(s)
288        if isinstance(d.get('description'), dict):
289            d['description'] = TranslatedText(d['description'])
290        return cls(**d)
@dataclass
class InfoInterface:
293@dataclass
294class InfoInterface:
295    """Network interface descriptor stored inside ``InfoMachine``."""
296
297    network: str
298    interface_name: str
299
300    def to_json(self) -> str:
301        return json.dumps(asdict(self))
302
303    @classmethod
304    def from_json(cls, json_data: str) -> "InfoInterface":
305        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:
300    def to_json(self) -> str:
301        return json.dumps(asdict(self))
@classmethod
def from_json(cls, json_data: str) -> InfoInterface:
303    @classmethod
304    def from_json(cls, json_data: str) -> "InfoInterface":
305        return cls(**json.loads(json_data))
@dataclass
class InfoMachine:
308@dataclass
309class InfoMachine:
310    """Runtime snapshot of a single container, written to ``info.json`` by the CLI and read by the GUI."""
311
312    name: str
313    status: str
314    allow_connection: bool
315    hidden: bool
316    interfaces: List[InfoInterface]
317    ports: List[str]
318    bridged: bool
319    x11_host: bool = False
320    color: str = ""
321    shape: str = ""
322
323    def to_json(self):
324        return json.dumps(asdict(self))
325
326    @classmethod
327    def from_json(cls, json_data: str) -> "InfoMachine":
328        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):
323    def to_json(self):
324        return json.dumps(asdict(self))
@classmethod
def from_json(cls, json_data: str) -> InfoMachine:
326    @classmethod
327    def from_json(cls, json_data: str) -> "InfoMachine":
328        return cls(**json.loads(json_data))
@dataclass
class InfoLab:
331@dataclass
332class InfoLab:
333    """Full lab metadata written to ``info.json`` at ``sre start`` and polled by the GUI every second.
334
335    ``informations`` (not ``description``) holds the Markdown lab description.
336    ``questions`` is a list of ``QuestionText | QuestionDummy | QuestionForm`` instances.
337    """
338
339    lab_name: str
340    lab_hash: str
341    title: TranslatedText
342    informations: TranslatedText
343    export_kathara_project: bool
344    allow_self_grade: bool
345    machines: List[InfoMachine]
346    questions: List[Question]
347    delay_between_self_grade: int
348    eval_interval_without_exam_mode: int
349    eval_before_exit: bool
350    user_allowed_states: dict
351    debug_project: bool = False
352    admin_only_states: list = field(default_factory=list)
353    default_language: str = ''
354    network_colors: dict = field(default_factory=dict)
355    network_shapes: dict = field(default_factory=dict)
356    show_nat_network: bool = False
357    nat_network_name: str = ''
358    nat_network_color: str = ''
359    host_network_exploded: bool = False
360    host_network_edge_relative_length: float = 1.0
361    schema_splines: str = 'curved'
362    schema_overlap: str = 'prism'
363
364    def to_json(self) -> str:
365        return json.dumps(asdict(self), indent=4)
366
367    @classmethod
368    def from_json(cls, s: str) -> "InfoLab":
369        d = json.loads(s)
370
371        def _wrap_q(item: dict) -> dict:
372            item = dict(item)
373            item['title'] = TranslatedText.from_value(item.get('title', ''))
374            item['description'] = TranslatedText.from_value(item.get('description', ''))
375            return item
376
377        return cls(
378            lab_name=d["lab_name"],
379            lab_hash=d["lab_hash"],
380            title=TranslatedText.from_value(d["title"]),
381            informations=TranslatedText.from_value(d.get("informations", "")),
382            export_kathara_project=d.get("export_kathara_project", True),
383            allow_self_grade=d.get("allow_self_grade", False),
384            debug_project=d.get("debug_project", False),
385            delay_between_self_grade=d.get("delay_between_self_grade", 0),
386            eval_interval_without_exam_mode=d.get("eval_interval_without_exam_mode", 0),
387            eval_before_exit=d.get("eval_before_exit", False),
388            user_allowed_states=d.get("user_allowed_states", {}),
389            admin_only_states=list(d.get("admin_only_states", []) or []),
390            default_language=d.get("default_language", ''),
391            network_colors=d.get("network_colors", {}),
392            network_shapes=d.get("network_shapes", {}),
393            show_nat_network=d.get("show_nat_network", False),
394            nat_network_name=d.get("nat_network_name", ''),
395            nat_network_color=d.get("nat_network_color", ''),
396            host_network_exploded=d.get("host_network_exploded", False),
397            host_network_edge_relative_length=float(d.get("host_network_edge_relative_length", 1.0)),
398            schema_splines=d.get("schema_splines", "curved"),
399            schema_overlap=d.get("schema_overlap", "prism"),
400            machines=[InfoMachine(**item) for item in d["machines"]],
401            questions=[
402                QuestionDummy(**_wrap_q(item)) if item.get("question_type") == QuestionType.DUMMY.value
403                else QuestionForm(**_wrap_q(item)) if item.get("question_type") == QuestionType.FORM.value
404                else QuestionText(**_wrap_q(item))
405                for item in d["questions"]
406            ],
407        )

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, 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')
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
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'
def to_json(self) -> str:
364    def to_json(self) -> str:
365        return json.dumps(asdict(self), indent=4)
@classmethod
def from_json(cls, s: str) -> InfoLab:
367    @classmethod
368    def from_json(cls, s: str) -> "InfoLab":
369        d = json.loads(s)
370
371        def _wrap_q(item: dict) -> dict:
372            item = dict(item)
373            item['title'] = TranslatedText.from_value(item.get('title', ''))
374            item['description'] = TranslatedText.from_value(item.get('description', ''))
375            return item
376
377        return cls(
378            lab_name=d["lab_name"],
379            lab_hash=d["lab_hash"],
380            title=TranslatedText.from_value(d["title"]),
381            informations=TranslatedText.from_value(d.get("informations", "")),
382            export_kathara_project=d.get("export_kathara_project", True),
383            allow_self_grade=d.get("allow_self_grade", False),
384            debug_project=d.get("debug_project", False),
385            delay_between_self_grade=d.get("delay_between_self_grade", 0),
386            eval_interval_without_exam_mode=d.get("eval_interval_without_exam_mode", 0),
387            eval_before_exit=d.get("eval_before_exit", False),
388            user_allowed_states=d.get("user_allowed_states", {}),
389            admin_only_states=list(d.get("admin_only_states", []) or []),
390            default_language=d.get("default_language", ''),
391            network_colors=d.get("network_colors", {}),
392            network_shapes=d.get("network_shapes", {}),
393            show_nat_network=d.get("show_nat_network", False),
394            nat_network_name=d.get("nat_network_name", ''),
395            nat_network_color=d.get("nat_network_color", ''),
396            host_network_exploded=d.get("host_network_exploded", False),
397            host_network_edge_relative_length=float(d.get("host_network_edge_relative_length", 1.0)),
398            schema_splines=d.get("schema_splines", "curved"),
399            schema_overlap=d.get("schema_overlap", "prism"),
400            machines=[InfoMachine(**item) for item in d["machines"]],
401            questions=[
402                QuestionDummy(**_wrap_q(item)) if item.get("question_type") == QuestionType.DUMMY.value
403                else QuestionForm(**_wrap_q(item)) if item.get("question_type") == QuestionType.FORM.value
404                else QuestionText(**_wrap_q(item))
405                for item in d["questions"]
406            ],
407        )