diff --git a/qas_editor/answer.py b/qas_editor/answer.py index dfdf970..42235e2 100644 --- a/qas_editor/answer.py +++ b/qas_editor/answer.py @@ -20,36 +20,19 @@ import logging from typing import TYPE_CHECKING, Dict, List, Callable from .enums import TolFormat, TextFormat, ShapeType, EmbeddedFormat, Direction,\ - TolType, Logic + TolType from .utils import Serializable, File, FText, TList, attribute_setup _LOG = logging.getLogger(__name__) -class Processor: - """Logic expression used to define the result of the question. - """ - - def __init__(self, key: str|Callable, value: str) -> None: - self.children: Dict[Logic, Processor] = None - self.key = key - self.val = value - - def any_n(self, *args): - pass - - def run(self): - for key, value in self.children.items(): - pass - - class Item: """This is an abstract class Question used as a parent for specific types of Questions. """ ANS_TYPE = None - def __init__(self, default_grade=1.0, feedbacks: Dict[float, FText] = None, - time_lim: int = 0, free_hints: list = None): + def __init__(self, feedbacks: Dict[str, FText] = None, + hints: Dict[str, FText] = None): """[summary] Args: name (str): name of the question @@ -58,33 +41,39 @@ def __init__(self, default_grade=1.0, feedbacks: Dict[float, FText] = None, general_feedback (str, optional): general feedback. dbid (int, optional): id number. """ - self.units = None - self._grade = float(default_grade) - self._time_lim = int(time_lim) - self._feedbacks = None - self._free_hints = TList(FText, free_hints) - self._options = [] - self._procs: Processor = None - - grade = attribute_setup(float, "_grade") - time_lim = attribute_setup(int, "_time_lim") + self._grading = None + self._feedbacks = feedbacks + self._hints = hints + self._options = None + + grading = attribute_setup(float, "_grading") feedbacks = attribute_setup(dict, "_feedbacks") - free_hints = attribute_setup(TList, "_free_hints") - procs = attribute_setup(TList, "_procs") + hints = attribute_setup(dict, "_free_hints") + options = attribute_setup(TList, "_options") + + +class ChoicesItem(Item): + """ + This is the basic class used to hold possible answers + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._options = TList(Choice) + + +class Choice(Item): + """ + This is the basic class used to hold possible answers + """ + + def __init__(self, fraction=0.0, text="", feedback: FText = None, + formatting: TextFormat = None): + self.fraction = fraction + options = attribute_setup(dict, "_options") - def check(self): - """Check if the instance parameters have valid values. Call this method - before exporting the instance, or right after modifying many valid of - a instance. - """ - if (not isinstance(self.name, str) or self._time_lim < 0 - or (self.dbid is not None and not isinstance(self.dbid, int)) - or self.default_grade < 0): - raise ValueError("Invalid value(s).") - for key, value in self._feedbacks.items(): - if not isinstance(key, float) or not isinstance(value, FText): - raise TypeError() + class Answer(Serializable): diff --git a/qas_editor/category.py b/qas_editor/category.py index e92675e..a3efc4d 100644 --- a/qas_editor/category.py +++ b/qas_editor/category.py @@ -24,7 +24,7 @@ from .utils import Serializable, File, FText from .question import _Question from .enums import Status -from ._parsers import aiken, csv_card, cloze, gift, json, kahoot, latex, \ +from .parsers import aiken, csv_card, cloze, gift, json, kahoot, latex, \ markdown, moodle, olx, ims if TYPE_CHECKING: from .utils import Dataset diff --git a/qas_editor/enums.py b/qas_editor/enums.py index 0b32952..e9c8661 100644 --- a/qas_editor/enums.py +++ b/qas_editor/enums.py @@ -124,13 +124,6 @@ class Numbering(EnhancedEnum): ROM_UR = "IIII", "IIII" -class Logic(Enum): - AND = auto() - OR = auto() - NOT = auto() - ELSE = auto() - - class OutFormat(Enum): """_summary_ """ diff --git a/qas_editor/_parsers/aiken.py b/qas_editor/parsers/aiken.py similarity index 91% rename from qas_editor/_parsers/aiken.py rename to qas_editor/parsers/aiken.py index bfc9380..800b811 100644 --- a/qas_editor/_parsers/aiken.py +++ b/qas_editor/parsers/aiken.py @@ -15,10 +15,11 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from __future__ import annotations import re import logging import glob -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Type from ..question import QMultichoice from ..utils import FText from ..enums import TextFormat @@ -46,14 +47,14 @@ def _from_question(buffer, line: str, name: str): answers[ord(_line[8].upper())-65].fraction = 100.0 break answers.append(Answer(0.0, match[1], None, TextFormat.PLAIN)) - question = FText(header.strip(), TextFormat.PLAIN) + question = FText([header.strip()]).from_string return QMultichoice(name=name, options=answers, question=question) # ----------------------------------------------------------------------------- -def read_aiken(cls: "Category", file_path: str, category: str = "$course$") -> "Category": +def read_aiken(cls: Type[Category], file_path: str, category: str = "$course$") -> Category: """_summary_ Args: @@ -75,7 +76,7 @@ def read_aiken(cls: "Category", file_path: str, category: str = "$course$") -> " return quiz -def write_aiken(category: "Category", file_path: str) -> None: +def write_aiken(category: Type[Category], file_path: str) -> None: """_summary_ Args: diff --git a/qas_editor/_parsers/cloze.py b/qas_editor/parsers/cloze.py similarity index 100% rename from qas_editor/_parsers/cloze.py rename to qas_editor/parsers/cloze.py diff --git a/qas_editor/_parsers/csv_card.py b/qas_editor/parsers/csv_card.py similarity index 100% rename from qas_editor/_parsers/csv_card.py rename to qas_editor/parsers/csv_card.py diff --git a/qas_editor/_parsers/gift.py b/qas_editor/parsers/gift.py similarity index 100% rename from qas_editor/_parsers/gift.py rename to qas_editor/parsers/gift.py diff --git a/qas_editor/_parsers/ims/__init__.py b/qas_editor/parsers/ims/__init__.py similarity index 100% rename from qas_editor/_parsers/ims/__init__.py rename to qas_editor/parsers/ims/__init__.py diff --git a/qas_editor/_parsers/ims/all.py b/qas_editor/parsers/ims/all.py similarity index 100% rename from qas_editor/_parsers/ims/all.py rename to qas_editor/parsers/ims/all.py diff --git a/qas_editor/_parsers/ims/bb.py b/qas_editor/parsers/ims/bb.py similarity index 100% rename from qas_editor/_parsers/ims/bb.py rename to qas_editor/parsers/ims/bb.py diff --git a/qas_editor/_parsers/ims/canvas.py b/qas_editor/parsers/ims/canvas.py similarity index 100% rename from qas_editor/_parsers/ims/canvas.py rename to qas_editor/parsers/ims/canvas.py diff --git a/qas_editor/_parsers/ims/imscc.py b/qas_editor/parsers/ims/imscc.py similarity index 100% rename from qas_editor/_parsers/ims/imscc.py rename to qas_editor/parsers/ims/imscc.py diff --git a/qas_editor/_parsers/ims/imscp.py b/qas_editor/parsers/ims/imscp.py similarity index 100% rename from qas_editor/_parsers/ims/imscp.py rename to qas_editor/parsers/ims/imscp.py diff --git a/qas_editor/_parsers/ims/qti1v2.py b/qas_editor/parsers/ims/qti1v2.py similarity index 100% rename from qas_editor/_parsers/ims/qti1v2.py rename to qas_editor/parsers/ims/qti1v2.py diff --git a/qas_editor/_parsers/ims/qti2v1.py b/qas_editor/parsers/ims/qti2v1.py similarity index 100% rename from qas_editor/_parsers/ims/qti2v1.py rename to qas_editor/parsers/ims/qti2v1.py diff --git a/qas_editor/_parsers/ims/qti3v0.py b/qas_editor/parsers/ims/qti3v0.py similarity index 100% rename from qas_editor/_parsers/ims/qti3v0.py rename to qas_editor/parsers/ims/qti3v0.py diff --git a/qas_editor/_parsers/json.py b/qas_editor/parsers/json.py similarity index 100% rename from qas_editor/_parsers/json.py rename to qas_editor/parsers/json.py diff --git a/qas_editor/_parsers/kahoot.py b/qas_editor/parsers/kahoot.py similarity index 100% rename from qas_editor/_parsers/kahoot.py rename to qas_editor/parsers/kahoot.py diff --git a/qas_editor/_parsers/latex.py b/qas_editor/parsers/latex.py similarity index 100% rename from qas_editor/_parsers/latex.py rename to qas_editor/parsers/latex.py diff --git a/qas_editor/_parsers/markdown.py b/qas_editor/parsers/markdown.py similarity index 100% rename from qas_editor/_parsers/markdown.py rename to qas_editor/parsers/markdown.py diff --git a/qas_editor/_parsers/moodle.py b/qas_editor/parsers/moodle.py similarity index 100% rename from qas_editor/_parsers/moodle.py rename to qas_editor/parsers/moodle.py diff --git a/qas_editor/_parsers/odf.py b/qas_editor/parsers/odf.py similarity index 100% rename from qas_editor/_parsers/odf.py rename to qas_editor/parsers/odf.py diff --git a/qas_editor/_parsers/olx.py b/qas_editor/parsers/olx.py similarity index 100% rename from qas_editor/_parsers/olx.py rename to qas_editor/parsers/olx.py diff --git a/qas_editor/question.py b/qas_editor/question.py index b6f9b37..29048e9 100644 --- a/qas_editor/question.py +++ b/qas_editor/question.py @@ -23,7 +23,7 @@ from .enums import EmbeddedFormat, Grading, RespFormat, ShowUnits, ShowAnswer, ShuffleType,\ Distribution, Numbering, Synchronise, TextFormat, Status from .utils import Serializable, MarkerError, AnswerError, File, Dataset, \ - FText, Hint, Unit, TList + FText, Hint, Unit, TList, attribute_setup from .answer import ACalculated, ACrossWord, Answer, EmbeddedItem, ANumerical,\ DragGroup, DragImage, SelectOption, Subquestion,\ DropZone, DragItem @@ -611,9 +611,9 @@ def __init__(self, name="qstn", dbid: int = None, tags: TList = None, self.name = str(name) self.dbid = int(dbid) if dbid else None self.notes = str(notes) - self.points = 0 + self._time_lim = 0 self._body = None - self._remarks = None + self._notes = None self._tags = TList(str, tags) self.__parent: Category = None _LOG.debug("New question (%s) created.", self) @@ -621,8 +621,8 @@ def __init__(self, name="qstn", dbid: int = None, tags: TList = None, def __str__(self) -> str: return f"{self.QNAME}: '{self.name}' @{hex(id(self))}" - body = FText.prop("_body", "Question body") - remarks = FText.prop("_remarks", "Solution or global feedback") + body = attribute_setup(FText, "_body", "Question body") + time_lim = attribute_setup(int, "_time_lim") @property def parent(self) -> Category: @@ -655,4 +655,5 @@ def check(self): raise ValueError("Invalid value(s).") for key, value in self._feedbacks.items(): if not isinstance(key, float) or not isinstance(value, FText): - raise TypeError() \ No newline at end of file + raise TypeError() + \ No newline at end of file diff --git a/qas_editor/utils.py b/qas_editor/utils.py index c54b930..1885c18 100644 --- a/qas_editor/utils.py +++ b/qas_editor/utils.py @@ -40,11 +40,10 @@ EXTRAS_FORMULAE = util.find_spec("sympy") is not None if EXTRAS_FORMULAE: - from sympy.parsing.sympy_parser import parse_expr - from sympy.parsing.latex import parse_latex from matplotlib import figure, font_manager, mathtext from matplotlib.backends import backend_agg from pyparsing import ParseFatalException # Part of matplotlib package + from sympy import printing, Expr _LOG = logging.getLogger(__name__) @@ -227,7 +226,7 @@ def setter(self, value): setattr(self, attr, value) elif value is not None: raise ValueError(f"Can't assign {value} to {attr}") - def getter(self) -> FText: + def getter(self): return getattr(self, attr) return property(getter, setter, doc=doc) @@ -546,7 +545,7 @@ def __str__(self) -> str: value += f"{key}={val} " value += ">" for child in self._children: - value += str(child) + value += FText.to_string(child) value = f"" return value @@ -569,7 +568,7 @@ def __str__(self) -> str: """ return str(self.root) - def _get_file_ref(self, tag: str, data: str): + def _get_file_ref(self, data: str): tag = self._stack[-1].tag attrs = self._stack[-1].attrs if tag == "file": @@ -604,9 +603,9 @@ def handle_endtag(self, tag): self._stack.pop() def handle_data(self, data): - if self._stack[-1].attrs not in ("a", "base", "base", "link", "audio", - "embed", "iframe", "img", "input", "script", - "source","track", "video", "file"): + if self._stack[-1].tag not in ("", "a", "base", "base", "input", "link", + "audio", "embed", "img", "video", "file", + "script", "source", "iframe", "track"): self._get_file_ref(data) else: self._stack[-1].append(data) @@ -621,113 +620,40 @@ def parse(self, data: str|TextIOWrapper): self.close() else: raise ParseFatalException() + return FText(self.root._children, self.files) class TextParser(): """A global text parser to generate FText instances """ - def __init__(self, text: str, check_tags: bool, files: List[File] = None, - token_map: dict = None): - self.stack = [] + def __init__(self, **_): self.ftext = [] - self.text = text - self._tmap = token_map + self.text = "" self.pos = 0 self.lst = 0 self.scp = False - self.stt = [0, False, 0] # Pos, escaped, Last pos - self.lastattr = None - self.check_tags = check_tags - self.files = files or [] def _wrapper(self, callback: Callable, size=1): - if self.text[self.stt[2]: self.stt[0]]: - self.ftext.append(self.text[self.stt[2]: self.stt[0]]) - self.stt[0] += size - self.stt[2] = self.stt[0] + if self.text[self.lst: self.pos]: + self.ftext.append(self.text[self.lst: self.pos]) + self.pos += size + self.lst = self.pos self.ftext.append(callback()) - self.stt[2] = self.stt[0] + 1 + self.lst = self.pos + 1 - def _get_file_ref(self, tag: str, attrs: dict): - if tag == "file": - path = attrs.pop("path", "/") + attrs.pop("name") - last = self.stt[0] + 1 - while self.text[self.stt[0]:self.stt[0]+2] != " LinkRef | None: - while self.text[self.stt[0]] != ">" and not self.stt[1]: - self._nxt() - tmp = self.text[self.stt[2]-1: self.stt[0] + 1] # includes final ">" - tag = tmp[1:-1].split(" ", 1)[0] - result = None - if tag in ("a", "base", "base", "link", "audio", "embed", "iframe", - "img", "input", "script", "source", "track", "video", "file"): - data = {k:v for k,v in re.findall(r"(\S+?)=\"(.+?)\"[ />]", tmp)} - result = self._get_file_ref(tag, data) - else: - result = tmp - if tag[0] == "/": - tmp = self.stack.pop() - if self.check_tags and tmp != tag[1:]: - raise ValueError(f"Tag {tag} is not being closed ({self.stt[2]})") - elif tmp[-2] != "/" and tag not in ("source", "area", "track", "input", - "col", "embed", "hr", "link", "meta", "br", "base", "wbr", "img"): - self.stack.append(tag) - return result - - - def _get_latex_exp(self): - while self.text[self.stt[0]] == ")" and self.stt[1]: # This is correct: "\(" - self._nxt() - return parse_latex(self.text[self.stt[2]: self.stt[0]]) + self.scp = (self.text[self.pos] == "\\") and not self.scp + self.pos += 1 def do(self): """Modify this functions in super classes. """ - if (self.text[self.stt[0]] == "<" and not self.stt[1]): - self._wrapper(self._get_tag) - elif EXTRAS_FORMULAE: - if self.text[self.stt[0]:self.stt[0]+2] == "{=" and not self.stt[1]: - self._wrapper(self._get_moodle_exp, 2) - elif self.text[self.stt[0]] == "(" and self.stt[1]: - self._wrapper(self._get_latex_exp) # This is correct: "\(" - elif self.text[self.stt[0]] == "{" and not self.stt[1]: - self._wrapper(self._get_moodle_var) - else: - for key, value in self._tmap.items(): - if self.text[self.stt[0]:self.stt[0]+len(key)] == key and not self.stt[1]: - if isinstance(value, str): - self._wrapper(getattr(self, value)) - else: - self._wrapper(value(self)) + pass def clean_up(self): - if self.text[self.stt[2]:]: - self.ftext.append(self.text[self.stt[2]:]) - if self.check_tags and len(self.stack) != 0: - raise ValueError(f"One or more tags are not closed: {self.stack}.") + if self.text[self.lst:]: + self.ftext.append(self.text[self.lst:]) def parse(self, data: str|TextIOWrapper): if isinstance(data, str): @@ -736,11 +662,11 @@ def parse(self, data: str|TextIOWrapper): self.text = data.read() else: raise ParseFatalException() - while self.stt[0] < len(self.text): + while self.pos < len(self.text): self.do() self._nxt() self.clean_up() - return FText(self.ftext, None, self.files) + return FText(self.ftext, None) class FText(Serializable): @@ -749,18 +675,38 @@ class FText(Serializable): text (list): """ - def __init__(self, text: str|List[str] = None, formatting = TextFormat.AUTO, - files: List[File] = None): + def __init__(self, text: list, files: List[File] = None): super().__init__() - self.formatting = TextFormat(formatting) self._text = text - if text and isinstance(text, list): - self._text = [text] self.files = files # Local reference of the list of files used. def __str__(self) -> str: return self.get() + @staticmethod + def to_string(item, math_type: MathType = None): + if isinstance(item, str) or hasattr(item, "__str__"): + return str(item) + elif hasattr(item, "MARKER_INT"): + return chr(item.MARKER_INT) + elif isinstance(item, LinkRef): + return item.get_tag() + elif EXTRAS_FORMULAE and isinstance(item, Expr): + if math_type == MathType.LATEX: + return f"$${printing.latex(item)}$$" + elif math_type == MathType.MOODLE: + return "{" + ("" if item.is_Atom else "=") + printing.latex(item) + "}" + elif math_type == MathType.MATHJAX: + return f"[mathjax]{printing.latex(item)}[/mathjax]" + elif math_type == MathType.MATHML: + return str(printing.mathml(item)) + elif math_type == MathType.ASCII: + return str(printing.pretty(item)) + elif math_type == MathType.FILE: + return render_latex(printing.latex(item), FileAddr.LOCAL) + else: + raise TypeError(f"Item has unknown type {type(item)}") + @property def text(self) -> List[str|LinkRef]: """A list of strings, file references, questions and math expressions @@ -782,8 +728,7 @@ def text(self, value): raise ValueError() @classmethod - def from_string(cls, text: str, formatting=TextFormat.AUTO, check_tags=True, - files: list = None, tagmap: dict=None) -> FText: + def from_string(cls, text: str, parser, **args) -> FText: """Parses the provided string to a FText class by finding file pointers and math expression and returning them as a list. Args: @@ -795,43 +740,21 @@ def from_string(cls, text: str, formatting=TextFormat.AUTO, check_tags=True, Returns: FText: _description_ """ - parser = TextParser(text, check_tags, files, tagmap) - parser.parse() - return cls(parser.ftext, formatting, parser.files) + if parser is None: + return cls([text], args.get("files")) + else: + return parser(**args).parse(text) - def get(self, math_type=MathType.ASCII, embedded=False) -> str: + def get(self, math_type=MathType.ASCII) -> str: """Get a string representation of the object Args: math_type (MathType, optional): Which type of Returns: str: A string representation of the object """ - from sympy.core import Pow - if EXTRAS_FORMULAE: - from sympy import printing, Expr data = "" - for item in self._text: # Suposse few item, so no poor performance - if isinstance(item, str): - data += str(item) - elif hasattr(item, "MARKER_INT"): - data += chr(item.MARKER_INT) - elif isinstance(item, LinkRef): - data += item.get_tag() - elif EXTRAS_FORMULAE and isinstance(item, Expr): - if math_type == MathType.LATEX: - data += f"$${printing.latex(item)}$$" - elif math_type == MathType.MOODLE: - data += "{" + ("" if item.is_Atom else "=") + printing.latex(item) + "}" - elif math_type == MathType.MATHJAX: - data += f"[mathjax]{printing.latex(item)}[/mathjax]" - elif math_type == MathType.MATHML: - data += str(printing.mathml(item)) - elif math_type == MathType.ASCII: - data += str(printing.pretty(item)) - elif math_type == MathType.FILE: - data += render_latex(printing.latex(item), FileAddr.LOCAL) - else: - raise TypeError(f"Item has unknown type {type(item)}") + for item in self._text: + data += self.to_string(item, math_type) return data @staticmethod diff --git a/test/test_api/test_ftext.py b/test/test_api/test_ftext.py index f1eb0bd..d9d15ad 100644 --- a/test/test_api/test_ftext.py +++ b/test/test_api/test_ftext.py @@ -20,13 +20,26 @@ from sympy import Symbol, sqrt from qas_editor.enums import MathType -from qas_editor.utils import File +from qas_editor.parsers.moodle import MoodleXHTMLParser TEST_PATH = os.path.dirname(__file__) SRC_PATH = os.path.abspath(os.path.join(TEST_PATH, '..')) X = Symbol("x") Y = Symbol("y") +def test_empty_plaintext(): + text = 'nothing' + parser = utils.TextParser + _ftext = utils.FText.from_string(text, parser) + assert _ftext.text == [text] + + +def test_empty_xtml(): + text = 'nothing' + parser = utils.XHTMLParser + _ftext = utils.FText.from_string(text, parser) + assert _ftext.text == [text] + def test_sympy_all(): s = ("

Moodle and fp latex package syntax " @@ -36,7 +49,7 @@ def test_sympy_all(): "the 'sqrt' function doesn't exist, need 'root(n, x)' in fp, " "{=sqrt(({x}-{y})*({x}+{y}))}

  • 'pi' is a function in moodle," " {=sin(1.5*pi())}
  • test with '- unary' expression" - " {=-{x}+(-{y}+2)}
  • ") + " {=-{x}+(-{y}+2)}
    Something outside") _results = utils.FText.from_string(s) assert _results.text == ["

    Moodle and fp " "latex package syntax is not always equivalent. Here some " @@ -50,16 +63,6 @@ def test_sympy_all(): '
    '] -def test_empty(): - _ftext = utils.FText.from_string('nothing') - assert _ftext.text == ['nothing'] - - -def test_var_ascii(): - _results = utils.FText.from_string('var {x}') - assert _results.get(MathType.ASCII) == "var x" - - def test_var_latex(): _results = utils.FText.from_string('var {x}') assert _results.get(MathType.LATEX) == "var $$x$$"