excel2moodle 0.6.1__py3-none-any.whl → 0.6.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
excel2moodle/__init__.py CHANGED
@@ -45,6 +45,8 @@ if __package__ is not None:
45
45
  "documentation": "https://jbosse3.gitlab.io/excel2moodle",
46
46
  "homepage": meta["project-url"].split()[1],
47
47
  "issues": "https://gitlab.com/jbosse3/excel2moodle/issues",
48
+ "funding": "https://ko-fi.com/jbosse3",
49
+ "API_id": "jbosse3%2Fexcel2moodle",
48
50
  }
49
51
 
50
52
 
excel2moodle/__main__.py CHANGED
@@ -5,17 +5,20 @@ import signal
5
5
  import sys
6
6
  from pathlib import Path
7
7
 
8
+ from PySide6.QtCore import QTimer
8
9
  from PySide6.QtWidgets import QApplication
9
10
 
10
11
  import excel2moodle
11
- from excel2moodle import e2mMetadata, mainLogger
12
+ from excel2moodle import __version__, e2mMetadata, mainLogger
12
13
  from excel2moodle.core import dataStructure
13
14
  from excel2moodle.core.settings import Settings, Tags
15
+ from excel2moodle.extra import updateQuery
14
16
  from excel2moodle.logger import loggerConfig
15
17
  from excel2moodle.ui import appUi as ui
16
18
 
17
19
 
18
20
  def main() -> None:
21
+ app = QApplication(sys.argv)
19
22
  excel2moodle.isMain = True
20
23
  settings = Settings()
21
24
  logfile = Path(settings.get(Tags.LOGFILE)).resolve()
@@ -24,12 +27,24 @@ def main() -> None:
24
27
  logfile.replace(f"{logfile}.old")
25
28
  logConfig.dictConfig(config=loggerConfig)
26
29
  signal.signal(signal.SIGINT, signal.SIG_DFL)
27
- app = QApplication(sys.argv)
28
- settings = Settings()
29
30
  database: dataStructure.QuestionDB = dataStructure.QuestionDB(settings)
30
31
  window = ui.MainWindow(settings, database)
31
32
  database.window = window
32
33
  window.show()
34
+ # Update check
35
+ latestTag = updateQuery.get_latest_tag(e2mMetadata.get("API_id"))
36
+ if latestTag is None:
37
+ mainLogger.warning(
38
+ "Couldn't check for Updates, maybe there is no internet connection"
39
+ )
40
+ if latestTag is not None and latestTag.strip("v") != __version__:
41
+ mainLogger.warning(
42
+ "A new Update is available: %s --> %s ", __version__, latestTag
43
+ )
44
+ changelog = updateQuery.get_changelog(e2mMetadata.get("API_id"))
45
+ # Delay showing the update dialog slightly
46
+ QTimer.singleShot(100, lambda: window.showUpdateDialog(changelog, latestTag))
47
+
33
48
  for k, v in e2mMetadata.items():
34
49
  msg = f"{k:^14s}: {v}"
35
50
  mainLogger.info(msg)
@@ -15,7 +15,7 @@ class BulletList:
15
15
  def __init__(self, rawBullets: list[str], qID: str) -> None:
16
16
  self.rawBullets: list[str] = rawBullets
17
17
  self.element: ET.Element = ET.Element("ul")
18
- self.bullets: dict[str, BulletP] = {}
18
+ self.bullets: dict[str | int, BulletP] = {}
19
19
  self.id = qID
20
20
  self.logger = LogAdapterQuestionID(loggerObj, {"qID": self.id})
21
21
  self._setupBullets(rawBullets)
@@ -70,7 +70,7 @@ class BulletList:
70
70
  if match is None:
71
71
  self.logger.debug("Got a normal bulletItem")
72
72
  num: float = float(quant.replace(",", "."))
73
- bulletName: str = str(i + 1)
73
+ bulletName = i + 1
74
74
  else:
75
75
  bulletName = match.group(1)
76
76
  num: float = 0.0
@@ -5,10 +5,9 @@ from typing import TYPE_CHECKING
5
5
  import lxml.etree as ET
6
6
  import pandas as pd
7
7
 
8
- from excel2moodle.core.settings import Tags
9
-
10
8
  if TYPE_CHECKING:
11
9
  from excel2moodle.core.question import Question
10
+ from excel2moodle.core.settings import Tags
12
11
 
13
12
  loggerObj = logging.getLogger(__name__)
14
13
 
@@ -25,12 +24,13 @@ class Category:
25
24
  ) -> None:
26
25
  """Instantiate a new Category object."""
27
26
  self.NAME = name
28
- match = re.search(r"\d+$", str(self.NAME))
29
- self.n: int = int(match.group(0)) if match else 99
27
+ match = re.search(r"\d+", str(self.NAME))
28
+ n = int(match.group(0)) if match else 99
29
+ self.n: int = n if n <= 99 and n >= 0 else 99
30
30
  self.desc = str(description)
31
31
  self.dataframe: pd.DataFrame = dataframe
32
32
  self.settings: dict[str, float | str] = settings if settings else {}
33
- self.questions: dict[int, Question] = {}
33
+ self._questions: dict[int, Question]
34
34
  self.maxVariants: int | None = None
35
35
  loggerObj.info("initializing Category %s", self.NAME)
36
36
 
@@ -50,6 +50,18 @@ class Category:
50
50
  def id(self) -> str:
51
51
  return f"{self.n:02d}"
52
52
 
53
+ @property
54
+ def questions(self) -> dict:
55
+ if not hasattr(self, "_questions"):
56
+ msg = f"Category {self.id} doesn't contain any valid questions."
57
+ raise ValueError(msg)
58
+ return self._questions
59
+
60
+ def appendQuestion(self, questionNumber: int, question) -> None:
61
+ if not hasattr(self, "_questions"):
62
+ self._questions: dict[int, Question] = {}
63
+ self._questions[questionNumber] = question
64
+
53
65
  def __hash__(self) -> int:
54
66
  return hash(self.NAME)
55
67
 
@@ -58,13 +70,20 @@ class Category:
58
70
  return self.NAME == other.NAME
59
71
  return False
60
72
 
61
- def getCategoryHeader(self) -> ET.Element:
73
+ def getCategoryHeader(self, subCategory: str | None = None) -> ET.Element:
62
74
  """Insert an <question type='category'> before all Questions of this Category."""
63
75
  header = ET.Element("question", type="category")
64
76
  cat = ET.SubElement(header, "category")
65
77
  info = ET.SubElement(header, "info", format="html")
66
- ET.SubElement(cat, "text").text = f"$module$/top/{self.NAME}"
78
+ catStr = (
79
+ f"$module$/top/{self.NAME}"
80
+ if subCategory is None
81
+ else f"$module$/top/{self.NAME}/Question-{subCategory[2:]}_Variants"
82
+ )
83
+ ET.SubElement(cat, "text").text = catStr
67
84
  ET.SubElement(info, "text").text = str(self.desc)
68
- ET.SubElement(header, "idnumber").text = self.id
85
+ ET.SubElement(header, "idnumber").text = (
86
+ f"cat-{self.id}" if subCategory is None else f"variants-{subCategory}"
87
+ )
69
88
  ET.indent(header)
70
89
  return header
@@ -3,6 +3,7 @@
3
3
  At the heart is the class ``xmlTest``
4
4
  """
5
5
 
6
+ import datetime as dt
6
7
  import logging
7
8
  import sys
8
9
  from concurrent.futures import ProcessPoolExecutor, as_completed
@@ -11,14 +12,16 @@ from typing import TYPE_CHECKING
11
12
 
12
13
  import lxml.etree as ET # noqa: N812
13
14
  import pandas as pd
15
+ import yaml
14
16
  from PySide6.QtCore import QObject, Signal
15
17
  from PySide6.QtWidgets import QDialog
16
18
 
19
+ from excel2moodle import __version__
17
20
  from excel2moodle.core import stringHelpers
18
21
  from excel2moodle.core.category import Category
19
22
  from excel2moodle.core.exceptions import InvalidFieldException, QNotParsedException
20
23
  from excel2moodle.core.globals import Tags
21
- from excel2moodle.core.question import Question
24
+ from excel2moodle.core.question import ParametricQuestion, Question
22
25
  from excel2moodle.core.settings import Settings
23
26
  from excel2moodle.core.validator import Validator
24
27
  from excel2moodle.logger import LogAdapterQuestionID
@@ -72,9 +75,10 @@ class QuestionDB:
72
75
  def __init__(self, settings: Settings) -> None:
73
76
  self.settings = settings
74
77
  self.window: QMainWindow | None = None
75
- self.version = None
76
78
  self.categoriesMetaData: pd.DataFrame
77
79
  self.categories: dict[str, Category]
80
+ self._exportedQuestions: list[Question] = []
81
+ self._exportedAll: bool = False
78
82
 
79
83
  @property
80
84
  def spreadsheet(self) -> Path:
@@ -94,6 +98,17 @@ class QuestionDB:
94
98
  ``categoriesMetaData`` dataframe
95
99
  Setup the categories and store them in ``self.categories = {}``
96
100
  Pass the question data to the categories.
101
+
102
+ Raises
103
+ ------
104
+ ValueError
105
+ When there is no 'seetings' worksheet in the file.
106
+ InvalidFieldException
107
+ When the settings are invalid
108
+ Or When the categories Sheet doesn't provide the necessary keys.
109
+
110
+ Before raising it logges the exceptions with a meaningful message.
111
+
97
112
  """
98
113
  sheetPath = sheetPath if sheetPath else self.spreadsheet
99
114
  logger.info("Start Parsing the Excel Metadata Sheet\n")
@@ -104,19 +119,14 @@ class QuestionDB:
104
119
  index_col=0,
105
120
  engine="calamine",
106
121
  )
107
- logger.debug("Found the settings: \n\t%s", settingDf)
108
- settingDf = self.harmonizeDFIndex(settingDf)
109
- for tag, value in settingDf.iterrows():
110
- val = value.iloc[0]
111
- if pd.notna(val):
112
- self.settings.set(tag, val)
113
- try:
114
- self._validateProjectSettings(sheetPath=sheetPath)
115
- except InvalidFieldException:
116
- logger.exception(
117
- "Can not create the database with invalid project settings."
118
- )
119
- raise
122
+ logger.debug("Found the settings: \n\t%s", settingDf)
123
+ settingDf = self.harmonizeDFIndex(settingDf)
124
+ for tag, value in settingDf.iterrows():
125
+ val = value.iloc[0]
126
+ if pd.notna(val):
127
+ self.settings.set(tag, val)
128
+
129
+ self._validateProjectSettings(sheetPath=sheetPath)
120
130
  with Path(sheetPath).open("rb") as f:
121
131
  self.categoriesMetaData = pd.read_excel(
122
132
  f,
@@ -124,10 +134,20 @@ class QuestionDB:
124
134
  index_col=0,
125
135
  engine="calamine",
126
136
  )
127
- logger.info("Sucessfully read categoriesMetaData")
137
+ if "description" not in self.categoriesMetaData.columns:
138
+ msg = f"You need to specify the 'description' tag for each category in the sheet '{self.settings.get(Tags.CATEGORIESSHEET)}'."
139
+ raise InvalidFieldException(msg, "0000", "description")
140
+ logger.info("Sucessfully read categoriesMetaData")
128
141
  return self.categoriesMetaData
129
142
 
130
143
  def _validateProjectSettings(self, sheetPath: Path) -> None:
144
+ if Tags.LOGLEVEL in self.settings:
145
+ level: str = self.settings.get(Tags.LOGLEVEL)
146
+ if level.upper() not in ("DEBUG", "INFO", "WARNING", "ERROR"):
147
+ self.settings.pop(Tags.LOGLEVEL)
148
+ logger.warning("You specified an unsupported Loglevel: %s", level)
149
+ if self.window is not None:
150
+ self.window.logHandler.setLevel(self.settings.get(Tags.LOGLEVEL).upper())
131
151
  if Tags.IMPORTMODULE in self.settings:
132
152
  logger.warning(
133
153
  "Appending: %s to sys.path. All names defined by it will be usable",
@@ -276,7 +296,7 @@ class QuestionDB:
276
296
  for qNum in category.dataframe.columns:
277
297
  try:
278
298
  self.setupAndParseQuestion(category, qNum)
279
- except (InvalidFieldException, QNotParsedException):
299
+ except (InvalidFieldException, QNotParsedException, AttributeError):
280
300
  logger.exception(
281
301
  "Question %s%02d couldn't be parsed. The Question Data: \n %s",
282
302
  category.id,
@@ -342,21 +362,26 @@ class QuestionDB:
342
362
  else:
343
363
  msg = "couldn't setup Parser"
344
364
  raise QNotParsedException(msg, question.id)
345
- category.questions[qNumber] = question
365
+ category.appendQuestion(qNumber, question)
346
366
  return question
347
367
 
348
368
  def appendQuestions(
349
- self, questions: list[QuestionItem], file: Path | None = None
369
+ self,
370
+ questions: list[QuestionItem],
371
+ file: Path | None = None,
372
+ pCount: int = 0,
373
+ qCount: int = 0,
350
374
  ) -> None:
351
375
  """Append selected question Elements to the tree."""
376
+ self._exportedQuestions.clear()
352
377
  tree = ET.Element("quiz")
353
378
  catdict: dict[Category, list[Question]] = {}
354
379
  for q in questions:
355
380
  logger.debug(f"got a question to append {q=}")
356
- cat = q.parent().getCategory()
381
+ cat = q.parent().category
357
382
  if cat not in catdict:
358
383
  catdict[cat] = []
359
- catdict[cat].append(q.getQuestion())
384
+ catdict[cat].append(q.question)
360
385
  for cat, qlist in catdict.items():
361
386
  self._appendQElements(
362
387
  cat,
@@ -365,6 +390,8 @@ class QuestionDB:
365
390
  includeHeader=self.settings.get(Tags.INCLUDEINCATS),
366
391
  )
367
392
  stringHelpers.printDom(tree, file=file)
393
+ if self.settings.get(Tags.GENEXPORTREPORT):
394
+ self.generateExportReport(file, pCount=pCount, qCount=qCount)
368
395
 
369
396
  def _appendQElements(
370
397
  self,
@@ -373,17 +400,80 @@ class QuestionDB:
373
400
  tree: ET.Element,
374
401
  includeHeader: bool = True,
375
402
  ) -> None:
376
- if includeHeader:
403
+ variant: int = self.settings.get(Tags.QUESTIONVARIANT)
404
+ if includeHeader or variant == -1:
377
405
  tree.append(cat.getCategoryHeader())
378
406
  logger.debug(f"Appended a new category item {cat=}")
379
- variant: int = self.settings.get(Tags.QUESTIONVARIANT)
407
+ self._exportedAll: bool = True
380
408
  for q in qList:
381
- if hasattr(q, "variants") and q.variants is not None:
382
- if variant == 0 or variant > q.variants:
383
- dialog = QuestionVariantDialog(self.window, q)
384
- if dialog.exec() == QDialog.Accepted:
385
- variant = dialog.variant
386
- logger.debug("Die Fragen-Variante %s wurde gewählt", variant)
387
- else:
388
- logger.warning("Keine Fragenvariante wurde gewählt.")
389
- tree.append(q.getUpdatedElement(variant=variant))
409
+ if not isinstance(q, ParametricQuestion):
410
+ tree.append(q.getUpdatedElement())
411
+ self._exportedQuestions.append(q)
412
+ continue
413
+ if variant == -1:
414
+ tree.append(cat.getCategoryHeader(subCategory=q.id))
415
+ for var in range(q.parametrics.variants):
416
+ tree.append(q.getUpdatedElement(variant=var))
417
+ elif variant == 0 or variant > q.parametrics.variants:
418
+ dialog = QuestionVariantDialog(self.window, q)
419
+ if dialog.exec() == QDialog.Accepted:
420
+ variant = dialog.variant
421
+ logger.debug("Die Fragen-Variante %s wurde gewählt", variant)
422
+ else:
423
+ logger.warning("Keine Fragenvariante wurde gewählt.")
424
+ tree.append(q.getUpdatedElement(variant=variant))
425
+ else:
426
+ tree.append(q.getUpdatedElement(variant=variant))
427
+ self._exportedQuestions.append(q)
428
+
429
+ def generateExportReport(
430
+ self, file: Path | None = None, pCount: int = 0, qCount: int = 0
431
+ ) -> None:
432
+ """Generate a YAML report of the exported questions."""
433
+ if not self._exportedQuestions:
434
+ return
435
+ if file:
436
+ base_path = file.with_name(f"{file.stem}_export_report.yaml")
437
+ else:
438
+ base_path = self.spreadsheet.parent / "export_report.yaml"
439
+
440
+ for i in range(99):
441
+ report_path = base_path.with_name(f"{base_path.stem}-{i:02d}.yaml")
442
+ if not report_path.resolve().exists():
443
+ break
444
+
445
+ report_data = {
446
+ "export_metadata": {
447
+ "export_time": dt.datetime.now(tz=None).strftime("%Y-%m-%d %H:%M:%S"),
448
+ "excel2moodle_version": __version__,
449
+ },
450
+ "categories": {},
451
+ }
452
+ if qCount != 0:
453
+ report_data["export_metadata"]["question_count"] = qCount
454
+ if pCount != 0:
455
+ report_data["export_metadata"]["total_point_count"] = pCount
456
+
457
+ sorted_questions = sorted(
458
+ self._exportedQuestions, key=lambda q: (q.category.name, q.id)
459
+ )
460
+
461
+ for question in sorted_questions:
462
+ category_name = question.category.name
463
+ if category_name not in report_data["categories"]:
464
+ report_data["categories"][category_name] = {
465
+ "description": question.category.desc,
466
+ "questions": [],
467
+ }
468
+
469
+ question_data = {"id": question.id, "name": question.name}
470
+ if isinstance(question, ParametricQuestion) and question.currentVariant > 0:
471
+ if self._exportedAll:
472
+ question_data["exported_variant"] = "all"
473
+ else:
474
+ question_data["exported_variant"] = question.currentVariant + 1
475
+
476
+ report_data["categories"][category_name]["questions"].append(question_data)
477
+
478
+ with report_path.open("w") as f:
479
+ yaml.dump(report_data, f, sort_keys=False)
@@ -5,9 +5,6 @@ This module host different functions. All of them will return an ``lxml.etree.El
5
5
 
6
6
  import lxml.etree as ET
7
7
 
8
- import excel2moodle.core.etHelpers as eth
9
- from excel2moodle.core.globals import TextElements, feedbackStr, feedBElements
10
-
11
8
  from .globals import Tags, XMLTags
12
9
 
13
10
 
@@ -48,20 +45,3 @@ def getCdatTxtElement(subEle: ET._Element | list[ET._Element]) -> ET.Element:
48
45
  ET.tostring(subEle, encoding="unicode", pretty_print=True),
49
46
  )
50
47
  return textEle
51
-
52
-
53
- def getFeedBEle(
54
- feedback: XMLTags,
55
- text: str | None = None,
56
- style: TextElements | None = None,
57
- ) -> ET.Element:
58
- """Gets ET Elements with the feedback for the question."""
59
- span = feedBElements[feedback] if style is None else style.create()
60
- if text is None:
61
- text = feedbackStr[feedback]
62
- ele = ET.Element(feedback, format="html")
63
- par = TextElements.PLEFT.create()
64
- span.text = text
65
- par.append(span)
66
- ele.append(eth.getCdatTxtElement(par))
67
- return ele
@@ -89,12 +89,3 @@ feedBElements = {
89
89
  XMLTags.ANSFEEDBACK: TextElements.SPANGREEN.create(),
90
90
  XMLTags.GENFEEDB: TextElements.SPANGREEN.create(),
91
91
  }
92
- feedbackStr = {
93
- XMLTags.CORFEEDB: "Die Frage wurde richtig beantwortet",
94
- XMLTags.PCORFEEDB: "Die Frage wurde teilweise richtig beantwortet",
95
- XMLTags.INCORFEEDB: "Die Frage wurde Falsch beantwortet",
96
- XMLTags.GENFEEDB: "Sie haben eine Antwort abgegeben",
97
- "right": "richtig",
98
- "wrong": "falsch",
99
- "right1Percent": "Gratultaion, die Frage wurde im Rahmen der Toleranz richtig beantwortet",
100
- }
@@ -9,7 +9,6 @@ from excel2moodle.core.globals import (
9
9
  Tags,
10
10
  TextElements,
11
11
  XMLTags,
12
- feedbackStr,
13
12
  feedBElements,
14
13
  )
15
14
  from excel2moodle.core.question import Picture, Question
@@ -30,7 +29,6 @@ class QuestionParser:
30
29
 
31
30
  def __init__(self) -> None:
32
31
  """Initialize the general Question parser."""
33
- self.genFeedbacks: list[XMLTags] = []
34
32
  self.logger: logging.LoggerAdapter
35
33
 
36
34
  def setup(self, question: Question) -> None:
@@ -84,7 +82,7 @@ class QuestionParser:
84
82
  It uses the data from ``self.rawInput`` if ``text`` is type``DFIndex``
85
83
  Otherwise the value of ``text`` will be inserted.
86
84
  """
87
- t = self.rawInput[text] if isinstance(text, Tags) else text
85
+ t = self.rawInput.get(text) if isinstance(text, Tags) else text
88
86
  if txtEle is False:
89
87
  self.tmpEle.append(eth.getElement(eleName, t, **attribs))
90
88
  elif txtEle is True:
@@ -111,12 +109,11 @@ class QuestionParser:
111
109
  self.appendToTmpEle(XMLTags.NAME, text=Tags.NAME, txtEle=True)
112
110
  self.appendToTmpEle(XMLTags.ID, text=self.question.id)
113
111
  textRootElem = ET.Element(XMLTags.QTEXT, format="html")
114
- mainTextEle = ET.SubElement(textRootElem, "text")
112
+ self.mainTextEle = ET.SubElement(textRootElem, "text")
115
113
  self.tmpEle.append(textRootElem)
116
- self.appendToTmpEle(XMLTags.POINTS, text=str(self.question.points))
114
+ if self.question.qtype != "CLOZE":
115
+ self.appendToTmpEle(XMLTags.POINTS, text=str(self.question.points))
117
116
  self._appendStandardTags()
118
- for feedb in self.genFeedbacks:
119
- self.tmpEle.append(eth.getFeedBEle(feedb))
120
117
 
121
118
  self.htmlRoot = ET.Element("div")
122
119
  self.htmlRoot.append(self.getMainTextElement())
@@ -139,10 +136,17 @@ class QuestionParser:
139
136
  if ansList is not None:
140
137
  for ele in ansList:
141
138
  self.tmpEle.append(ele)
139
+ self._finalizeParsing()
140
+
141
+ def _finalizeParsing(self) -> None:
142
+ """Pass the parsed element trees to the question.
143
+
144
+ Intended for the subclasses to do extra stuff.
145
+ """
142
146
  self.question._element = self.tmpEle
143
147
  self.question.htmlRoot = self.htmlRoot
144
148
  self.question.isParsed = True
145
- self.question.textElement = mainTextEle
149
+ self.question.textElement = self.mainTextEle
146
150
  self.logger.info("Sucessfully parsed")
147
151
 
148
152
  def getFeedBEle(
@@ -153,7 +157,8 @@ class QuestionParser:
153
157
  ) -> ET.Element:
154
158
  span = feedBElements[feedback] if style is None else style.create()
155
159
  if text is None:
156
- text = feedbackStr[feedback]
160
+ self.logger.error("Giving a feedback without providing text is nonsens")
161
+ text = self.rawInput.get(Tags.GENERALFB)
157
162
  ele = ET.Element(feedback, format="html")
158
163
  par = TextElements.PLEFT.create()
159
164
  span.text = text
@@ -169,6 +174,7 @@ class QuestionParser:
169
174
  self,
170
175
  result: float = 0.0,
171
176
  fraction: float = 100,
177
+ feedback: str | None = None,
172
178
  format: str = "moodle_auto_format",
173
179
  ) -> ET.Element:
174
180
  """Get ``<answer/>`` Element specific for the numerical Question.
@@ -184,11 +190,13 @@ class QuestionParser:
184
190
  fraction=str(fraction),
185
191
  format=format,
186
192
  )
193
+ if feedback is None:
194
+ feedback = self.rawInput.get(Tags.TRUEFB)
187
195
  ansEle.append(
188
- eth.getFeedBEle(
189
- XMLTags.ANSFEEDBACK,
190
- feedbackStr["right1Percent"],
191
- TextElements.SPANGREEN,
196
+ self.getFeedBEle(
197
+ feedback=XMLTags.ANSFEEDBACK,
198
+ text=feedback,
199
+ style=TextElements.SPANGREEN,
192
200
  ),
193
201
  )
194
202
  absTolerance = round(result * self.rawInput.get(Tags.TOLERANCE), 4)
@@ -2,6 +2,7 @@ import base64
2
2
  import logging
3
3
  import math
4
4
  import re
5
+ from copy import deepcopy
5
6
  from pathlib import Path
6
7
  from types import UnionType
7
8
  from typing import TYPE_CHECKING, ClassVar, Literal, overload
@@ -37,12 +38,23 @@ class QuestionData(dict):
37
38
  @overload
38
39
  def get(
39
40
  self,
40
- key: Literal[Tags.NAME, Tags.ANSTYPE, Tags.PICTURE, Tags.EQUATION],
41
+ key: Literal[
42
+ Tags.NAME,
43
+ Tags.ANSTYPE,
44
+ Tags.PICTURE,
45
+ Tags.EQUATION,
46
+ Tags.TRUEFB,
47
+ Tags.FALSEFB,
48
+ Tags.GENERALFB,
49
+ Tags.WRONGSIGNFB,
50
+ ],
51
+ default: object = None,
41
52
  ) -> str: ...
42
53
  @overload
43
54
  def get(
44
55
  self,
45
56
  key: Literal[Tags.BPOINTS, Tags.TRUE, Tags.FALSE, Tags.QUESTIONPART, Tags.TEXT],
57
+ default: object = None,
46
58
  ) -> list: ...
47
59
  @overload
48
60
  def get(
@@ -53,19 +65,22 @@ class QuestionData(dict):
53
65
  Tags.ANSPICWIDTH,
54
66
  Tags.WRONGSIGNPERCENT,
55
67
  ],
68
+ default: object = None,
56
69
  ) -> int: ...
57
70
  @overload
58
71
  def get(
59
- self, key: Literal[Tags.PARTTYPE, Tags.TYPE]
72
+ self, key: Literal[Tags.PARTTYPE, Tags.TYPE], default: object = None
60
73
  ) -> Literal["MC", "NFM", "CLOZE"]: ...
61
74
  @overload
62
75
  def get(
63
- self, key: Literal[Tags.TOLERANCE, Tags.POINTS, Tags.FIRSTRESULT]
76
+ self,
77
+ key: Literal[Tags.TOLERANCE, Tags.POINTS, Tags.FIRSTRESULT],
78
+ default: object = None,
64
79
  ) -> float: ...
65
80
  @overload
66
- def get(self, key: Literal[Tags.RESULT]) -> float | str: ...
81
+ def get(self, key: Literal[Tags.RESULT], default: object = None) -> float | str: ...
67
82
 
68
- def get(self, key: Tags, default=None):
83
+ def get(self, key, default=None):
69
84
  """Get the value for `key` with correct type.
70
85
 
71
86
  If `key == Tags.TOLERANCE` the tolerance is checked to be a perc. fraction
@@ -172,7 +187,7 @@ class Question:
172
187
  self.textElement.text = ET.CDATA(
173
188
  ET.tostring(self.htmlRoot, encoding="unicode", pretty_print=True)
174
189
  )
175
- return self._element
190
+ return deepcopy(self._element)
176
191
 
177
192
  def _setID(self, id=0) -> None:
178
193
  if id == 0:
@@ -186,6 +201,11 @@ class ParametricQuestion(Question):
186
201
  super().__init__(*args, **kwargs)
187
202
  self.rules: list[str] = []
188
203
  self.parametrics: Parametrics
204
+ self._variant: int
205
+
206
+ @property
207
+ def currentVariant(self) -> int:
208
+ return self._variant
189
209
 
190
210
  def getUpdatedElement(self, variant: int = 0) -> ET.Element:
191
211
  """Update the bulletItem.text With the values for variant.
@@ -201,6 +221,7 @@ class ParametricQuestion(Question):
201
221
  self.bulletList.updateBullets(
202
222
  variables=self.parametrics.variables, variant=variant
203
223
  )
224
+ self._variant = variant
204
225
  return super().getUpdatedElement(variant)
205
226
 
206
227
 
@@ -272,32 +293,31 @@ class Parametrics:
272
293
  self.results[num] = []
273
294
  for variant in range(self.variants):
274
295
  type(self).setupAstIntprt(self._variables, variant)
275
- self.logger.debug("Setup The interpreter for variant: %s", variant)
296
+ self.logger.debug("Setup The interpreter for variant: %s", variant + 1)
276
297
  for num, eq in self.equations.items():
277
298
  result = type(self).astEval(eq)
299
+ if not isinstance(result, float | int):
300
+ msg = f"The expression: '{eq}' = {result} could not be evaluated."
301
+ raise QNotParsedException(msg, self.id)
278
302
  self.logger.info(
279
303
  "Calculated expr. %s (variant %s): %s = %.3f ",
280
304
  num,
281
- variant,
305
+ variant + 1,
282
306
  eq,
283
307
  result,
284
308
  )
285
- if isinstance(result, float | int):
286
- if variant == 0 and not math.isclose(
287
- result, self._resultChecker[num], rel_tol=0.01
288
- ):
289
- self.logger.warning(
290
- "The calculated result %s differs from given firstResult: %s",
291
- result,
292
- self._resultChecker,
293
- )
294
- self.logger.debug(
295
- "Calculated result %s for variant %s", result, variant
309
+ if variant == 0 and not math.isclose(
310
+ result, self._resultChecker[num], rel_tol=0.01
311
+ ):
312
+ self.logger.warning(
313
+ "The calculated result %s differs from given firstResult: %s",
314
+ result,
315
+ self._resultChecker,
296
316
  )
297
- self.results[num].append(round(result, 3))
298
- else:
299
- msg = f"The expression {eq} lead to: {result=} could not be evaluated."
300
- raise QNotParsedException(msg, self.id)
317
+ self.logger.debug(
318
+ "Calculated result %s for variant %s", result, variant
319
+ )
320
+ self.results[num].append(round(result, 3))
301
321
  return self.results
302
322
 
303
323
  def resetVariables(self) -> None: