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 )
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.
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.
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.
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).
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.
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
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)
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.
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
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)
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.
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
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)
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.
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)
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.
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.
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.
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.
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 )