diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index c919b1b..3ae4877 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -27,8 +27,8 @@ jobs: sudo apt install -y texlive-latex-extra graphviz - name: Install Python Dependencies run: | - python -m pip install --upgrade pip - pip install poetry + python -m pip install --upgrade pip setuptools wheel virtualenv + python -m pip install poetry poetry config virtualenvs.create false poetry install - id: deployment diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d35f9c6..c0288e3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,19 +16,19 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install Python dependencies run: | - python -m pip install --upgrade pip + python -m pip install --upgrade setuptools wheel pip install poetry - poetry config virtualenvs.create false - poetry install + poetry config virtualenvs.create true + poetry install --with dev - name: Install Pandoc # apt version seems too old uses: r-lib/actions/setup-pandoc@v2 - name: Linting Checks run: | - black --check . - isort --check-only in2lambda docs - pydocstyle --convention=google in2lambda + poetry run black . + poetry run isort --check-only in2lambda docs + poetry run pydocstyle --convention=google in2lambda - name: pytest - run: pytest --cov-report=xml:coverage.xml --cov=in2lambda --doctest-modules in2lambda + run: poetry run pytest --cov-report=xml:coverage.xml --cov=in2lambda --doctest-modules in2lambda - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: diff --git a/.metals/metals.lock.db b/.metals/metals.lock.db new file mode 100644 index 0000000..42b0b48 --- /dev/null +++ b/.metals/metals.lock.db @@ -0,0 +1,6 @@ +#FileLock +#Mon Aug 04 15:03:28 BST 2025 +hostName=localhost +id=19860ab936622402fe2f44aa1920af12cbe404b7acd +method=file +server=localhost\:45175 diff --git a/docs/source/contributing/documentation.md b/docs/source/contributing/documentation.md index 1cdb6f8..cf52670 100644 --- a/docs/source/contributing/documentation.md +++ b/docs/source/contributing/documentation.md @@ -50,5 +50,5 @@ The [CLT reference](../reference/command-line) is generated based on the output [API documentation](../reference/library) is built based on [docstrings](https://peps.python.org/pep-0257/#what-is-a-docstring) found within the code base. This is done using [sphinx.ext.autosummary](https://www.sphinx-doc.org/en/master/usage/extensions/autosummary.html). :::{tip} -See the [Module docs](../reference/_autosummary/in2lambda.api.module.Module) and the source code buttons on its page for good examples on how to write the docstrings effectively. +See the [Set docs](../reference/_autosummary/in2lambda.api.set.Set) and the source code buttons on its page for good examples on how to write the docstrings effectively. ::: diff --git a/in2lambda/api/module.py b/in2lambda/api/module.py deleted file mode 100644 index 0cf404d..0000000 --- a/in2lambda/api/module.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Represents a list of questions.""" - -from dataclasses import dataclass, field -from typing import Union - -import panflute as pf - -from in2lambda.api.question import Question -from in2lambda.json_convert import json_convert - - -@dataclass -class Module: - """Represents a list of questions.""" - - questions: list[Question] = field(default_factory=list) - _current_question_index = -1 - - @property - def current_question(self) -> Question: - """The current question being modified, or Question("INVALID") if there are no questions. - - The reasoning behind returning Question("INVALID") is in case filter logic is being applied - on text before the first question (e.g. intro paragraphs). In that case, there is no effect. - - Returns: - The current question or Question("INVALID") if there are no questions. - - Examples: - >>> from in2lambda.api.module import Module - >>> Module().current_question - Question(title='INVALID', parts=[], images=[], main_text='') - >>> module = Module() - >>> module.add_question() - >>> module.current_question - Question(title='', parts=[], images=[], main_text='') - """ - return ( - self.questions[self._current_question_index] - if self.questions - else Question("INVALID") - ) - - def add_question( - self, title: str = "", main_text: Union[pf.Element, str] = pf.Str("") - ) -> None: - """Inserts a new question into the module. - - Args: - title: An optional string for the title of the question. If no title - is provided, the question title auto-increments i.e. Question 1, 2, etc. - main_text: An optional string or panflute element for the main question text. - - Examples: - >>> from in2lambda.api.module import Module - >>> import panflute as pf - >>> module = Module() - >>> module.add_question("Some title", pf.Para(pf.Str("hello"), pf.Space, pf.Str("there"))) - >>> module - Module(questions=[Question(title='Some title', parts=[], images=[], main_text='hello there')]) - >>> module.add_question(main_text="Normal string text") - >>> module.questions[1].main_text - 'Normal string text' - """ - question = Question(title=title) - question.main_text = main_text - self.questions.append(question) - - def increment_current_question(self) -> None: - """Manually overrides the current question being modified. - - The default (-1) indicates the last question added. Incrementing for the - first time sets to 0 i.e. the first question. - - The is useful if adding question text first and answers later. - - Examples: - >>> from in2lambda.api.module import Module - >>> module = Module() - >>> # Imagine adding the questions from a question file first... - >>> module.add_question("Question 1") - >>> module.add_question("Question 2") - >>> # ...and then adding solutions from an answer file later - >>> module.increment_current_question() # Loop back to question 1 - >>> module.current_question.add_solution("Question 1 answer") - >>> module.increment_current_question() - >>> module.current_question.add_solution("Question 2 answer") - >>> module.questions - [Question(title='Question 1', parts=[Part(text='', worked_solution='Question 1 answer')], images=[], main_text=''),\ - Question(title='Question 2', parts=[Part(text='', worked_solution='Question 2 answer')], images=[], main_text='')] - """ - self._current_question_index += 1 - - def to_json(self, output_dir: str) -> None: - """Turns this module into Lambda Feedback JSON/ZIP files. - - WARNING: This will overwrite any existing files in the directory. - - Args: - output_dir: Where to output the final Lambda Feedback JSON/ZIP files. - - Examples: - >>> import tempfile - >>> import os - >>> import json - >>> # Create a module with two questions - >>> module = Module() - >>> module.add_question("Question 1") - >>> module.add_question("Question 2") - >>> with tempfile.TemporaryDirectory() as temp_dir: - ... # Write the JSON files to the temporary directory - ... module.to_json(temp_dir) - ... # Check the contents of the directory - ... sorted(os.listdir(temp_dir)) - ... # Check the contents of the set directory - ... sorted(os.listdir(f"{temp_dir}/set")) - ... # Check the title of the first question - ... with open(f"{temp_dir}/set/question_1.json") as file: - ... print(f"Question 1's title: {json.load(file)['title']}") - ['set', 'set.zip'] - ['media', 'question_1.json', 'question_2.json'] - Question 1's title: Question 1 - - """ - json_convert.main(self.questions, output_dir) diff --git a/in2lambda/api/question.py b/in2lambda/api/question.py index 997360e..6ee42d4 100644 --- a/in2lambda/api/question.py +++ b/in2lambda/api/question.py @@ -1,4 +1,4 @@ -"""A full question with optional parts that's contained in a module.""" +"""A full question with optional parts that's contained in a set.""" from dataclasses import dataclass, field from typing import Union diff --git a/in2lambda/api/set.py b/in2lambda/api/set.py new file mode 100644 index 0000000..58c7a52 --- /dev/null +++ b/in2lambda/api/set.py @@ -0,0 +1,185 @@ +"""Represents a list of questions.""" + +from dataclasses import dataclass, field +from typing import Union + +import panflute as pf + +from in2lambda.api.question import Question +from in2lambda.api.visibility_status import VisibilityController, VisibilityStatus + + +@dataclass +class Set: + """Represents a list of questions.""" + + _name: str = field(default="set") + _description: str = field(default="") + _finalAnswerVisibility: VisibilityController = field( + default_factory=lambda: VisibilityController( + VisibilityStatus.OPEN_WITH_WARNINGS + ) + ) + _workedSolutionVisibility: VisibilityController = field( + default_factory=lambda: VisibilityController( + VisibilityStatus.OPEN_WITH_WARNINGS + ) + ) + _structuredTutorialVisibility: VisibilityController = field( + default_factory=lambda: VisibilityController(VisibilityStatus.OPEN) + ) + + questions: list[Question] = field(default_factory=list) + _current_question_index = -1 + + @property + def current_question(self) -> Question: + """The current question being modified, or Question("INVALID") if there are no questions. + + The reasoning behind returning Question("INVALID") is in case filter logic is being applied + on text before the first question (e.g. intro paragraphs). In that case, there is no effect. + + Returns: + The current question or Question("INVALID") if there are no questions. + + Examples: + >>> from in2lambda.api.set import Set + >>> Set().current_question + Question(title='INVALID', parts=[], images=[], main_text='') + >>> s = Set() + >>> s.add_question() + >>> s.current_question + Question(title='', parts=[], images=[], main_text='') + """ + return ( + self.questions[self._current_question_index] + if self.questions + else Question("INVALID") + ) + + def add_question( + self, title: str = "", main_text: Union[pf.Element, str] = pf.Str("") + ) -> None: + """Inserts a new question into the set. + + Args: + title: An optional string for the title of the question. If no title + is provided, the question title auto-increments i.e. Question 1, 2, etc. + main_text: An optional string or panflute element for the main question text. + + Examples: + >>> from in2lambda.api.set import Set + >>> import panflute as pf + >>> s = Set() + >>> s.add_question("Some title", pf.Para(pf.Str("hello"), pf.Space, pf.Str("there"))) + >>> s.questions + [Question(title='Some title', parts=[], images=[], main_text='hello there')] + >>> s.add_question(main_text="Normal string text") + >>> s.questions[1].main_text + 'Normal string text' + """ + question = Question(title=title) + question.main_text = main_text + self.questions.append(question) + + def increment_current_question(self) -> None: + """Manually overrides the current question being modified. + + The default (-1) indicates the last question added. Incrementing for the + first time sets to 0 i.e. the first question. + + The is useful if adding question text first and answers later. + + Examples: + >>> from in2lambda.api.set import Set + >>> s = Set() + >>> # Imagine adding the questions from a question file first... + >>> s.add_question("Question 1") + >>> s.add_question("Question 2") + >>> # ...and then adding solutions from an answer file later + >>> s.increment_current_question() # Loop back to question 1 + >>> s.current_question.add_solution("Question 1 answer") + >>> s.increment_current_question() + >>> s.current_question.add_solution("Question 2 answer") + >>> s.questions + [Question(title='Question 1', parts=[Part(text='', worked_solution='Question 1 answer')], images=[], main_text=''),\ + Question(title='Question 2', parts=[Part(text='', worked_solution='Question 2 answer')], images=[], main_text='')] + """ + self._current_question_index += 1 + + def to_json(self, output_dir: str) -> None: + """Turns this set into Lambda Feedback JSON/ZIP files. + + WARNING: This will overwrite any existing files in the directory. + + Args: + output_dir: Where to output the final Lambda Feedback JSON/ZIP files. + + Examples: + >>> import tempfile + >>> import os + >>> import json + >>> # Create a set with two questions + >>> s = Set() + >>> s.add_question("Question 1") + >>> s.add_question("Question 2") + >>> with tempfile.TemporaryDirectory() as temp_dir: + ... # Write the JSON files to the temporary directory + ... s.to_json(temp_dir) + ... # Check the contents of the directory + ... sorted(os.listdir(temp_dir)) + ... # Check the contents of the set directory + ... sorted(os.listdir(f"{temp_dir}/set")) + ... # Check the title of the first question + ... with open(f"{temp_dir}/set/question_000_Question_1.json") as file: + ... print(f"Question 1's title: {json.load(file)['title']}") + ['set', 'set.zip'] + ['question_000_Question_1.json', 'question_001_Question_2.json', 'set_set.json'] + Question 1's title: Question 1 + """ + from in2lambda.json_convert import json_convert + + json_convert.main(self, output_dir) + + def set_name(self, name: str) -> None: + """Sets the name of the set. + + Args: + name: The name to set for the set. + + Examples: + >>> from in2lambda.api.set import Set + >>> s = Set() + >>> s.set_name("My Question Set") + >>> s._name + 'My Question Set' + """ + self._name = name + + def set_description(self, description: str) -> None: + """Sets the description of the set. + + Args: + description: The description to set for the set. + + Examples: + >>> from in2lambda.api.set import Set + >>> s = Set() + >>> s.set_description("This is my question set.") + >>> s._description + 'This is my question set.' + """ + self._description = description + + def __repr__(self): + """Custom representation showing visibility status values instead of memory addresses.""" + return ( + f"Set(" + f"_name={self._name!r}, " + f"_description={self._description!r}, " + f"_finalAnswerVisibility={str(self._finalAnswerVisibility)!r}, " + f"_workedSolutionVisibility={str(self._workedSolutionVisibility)!r}, " + f"_structuredTutorialVisibility={str(self._structuredTutorialVisibility)!r}, " + f"questions={self.questions!r}" + f")" + ) diff --git a/in2lambda/api/visibility_status.py b/in2lambda/api/visibility_status.py new file mode 100644 index 0000000..541c97d --- /dev/null +++ b/in2lambda/api/visibility_status.py @@ -0,0 +1,76 @@ +"""Module for managing visibility status of questions and sets.""" + +from enum import Enum + +class VisibilityStatus(Enum): + """Enum representing the visibility status of a question or set.""" + + OPEN = "OPEN" + HIDE = "HIDE" + OPEN_WITH_WARNINGS = "OPEN_WITH_WARNINGS" + + def __str__(self): + """Return the string representation of the visibility status.""" + return self.value + + def __repr__(self) -> str: + """Return a string representation for debugging.""" + return str(self) + + +class VisibilityController: + """Controller for managing visibility status with easy-to-use methods.""" + + def __init__(self, initial_status: VisibilityStatus = VisibilityStatus.OPEN): + """Initialize the VisibilityController with a specific status.""" + self._status = initial_status + + @property + def status(self) -> VisibilityStatus: + """Return the current visibility status.""" + return self._status + + def to_open(self): + """Change status to OPEN. + + Example: + >>> vc = VisibilityController() + >>> vc.to_open() + >>> vc.status + OPEN + """ + self._status = VisibilityStatus.OPEN + + def to_hide(self): + """Change status to HIDE. + + Example: + >>> vc = VisibilityController() + >>> vc.to_hide() + >>> vc.status + HIDE + """ + self._status = VisibilityStatus.HIDE + + def to_open_with_warnings(self): + """Change status to OPEN_WITH_WARNINGS. + + Example: + >>> vc = VisibilityController() + >>> vc.to_open_with_warnings() + >>> vc.status + OPEN_WITH_WARNINGS + """ + self._status = VisibilityStatus.OPEN_WITH_WARNINGS + + def __str__(self): + """Return the string representation of the visibility status.""" + return str(self._status) + + def __repr__(self) -> str: + """Return a string representation for debugging.""" + return str(self) + + def to_dict(self): + """Convert VisibilityController to dictionary for JSON serialization.""" + return {"status": str(self._status)} diff --git a/in2lambda/filters/PartPartSolSol/filter.py b/in2lambda/filters/PartPartSolSol/filter.py index a8d3d55..b825161 100644 --- a/in2lambda/filters/PartPartSolSol/filter.py +++ b/in2lambda/filters/PartPartSolSol/filter.py @@ -6,7 +6,7 @@ import panflute as pf -from in2lambda.api.module import Module +from in2lambda.api.set import Set from in2lambda.filters.markdown import filter @@ -14,7 +14,7 @@ def pandoc_filter( elem: pf.Element, doc: pf.elements.Doc, - module: Module, + set: Set, parsing_answers: bool, ) -> Optional[pf.Str]: """A Pandoc filter that parses and translates various TeX elements. @@ -23,7 +23,7 @@ def pandoc_filter( elem: The current TeX element being processed. This could be a paragraph, ordered list, etc. doc: A Pandoc document container - essentially the Pandoc AST. - module: The Python API that is used to store the result after processing + set: The Python API that is used to store the result after processing the TeX file. parsing_answers: Whether an answers-only document is currently being parsed. @@ -46,10 +46,10 @@ def pandoc_filter( # Solutions are in a Div if isinstance(elem, pf.Div): - module.add_question(main_text="\n".join(pandoc_filter.question)) + set.add_question(main_text="\n".join(pandoc_filter.question)) if hasattr(pandoc_filter, "parts"): for part in pandoc_filter.parts: - module.current_question.add_part_text(part) + set.current_question.add_part_text(part) pandoc_filter.question = [] pandoc_filter.parts = [] @@ -58,8 +58,8 @@ def pandoc_filter( match type(first_answer_part := elem.content[0]): case pf.OrderedList: for item in first_answer_part.content: - module.current_question.add_solution(pf.stringify(item)) + set.current_question.add_solution(pf.stringify(item)) case pf.Para: - module.current_question.add_solution(pf.stringify(elem)) + set.current_question.add_solution(pf.stringify(elem)) return None diff --git a/in2lambda/filters/PartSolPartSol/filter.py b/in2lambda/filters/PartSolPartSol/filter.py index ab81937..f9f3736 100644 --- a/in2lambda/filters/PartSolPartSol/filter.py +++ b/in2lambda/filters/PartSolPartSol/filter.py @@ -7,7 +7,7 @@ import panflute as pf -from in2lambda.api.module import Module +from in2lambda.api.set import Set from in2lambda.filters.markdown import filter @@ -15,7 +15,7 @@ def pandoc_filter( elem: pf.Element, doc: pf.elements.Doc, - module: Module, + set: Set, parsing_answers: bool, ) -> Optional[pf.Str]: """A Pandoc filter that parses and translates various TeX elements. @@ -24,7 +24,7 @@ def pandoc_filter( elem: The current TeX element being processed. This could be a paragraph, ordered list, etc. doc: A Pandoc document container - essentially the Pandoc AST. - module: The Python API that is used to store the result after processing + set: The Python API that is used to store the result after processing the TeX file. parsing_answers: Whether an answers-only document is currently being parsed. @@ -48,16 +48,14 @@ def pandoc_filter( for item in listItem.content if not isinstance(item, pf.Div) ] - module.current_question.add_part_text("\n".join(part)) - module.current_question.add_solution( - pandoc_filter.solutions.popleft() - ) + set.current_question.add_part_text("\n".join(part)) + set.current_question.add_solution(pandoc_filter.solutions.popleft()) if isinstance(elem, pf.Div): pandoc_filter.solutions.append(pf.stringify(elem)) if pandoc_filter.question: - module.add_question(main_text="\n".join(pandoc_filter.question)) - module.current_question.add_solution(pf.stringify(elem)) - module.current_question._last_part["solution"] -= 1 + set.add_question(main_text="\n".join(pandoc_filter.question)) + set.current_question.add_solution(pf.stringify(elem)) + set.current_question._last_part["solution"] -= 1 pandoc_filter.question = [] return None diff --git a/in2lambda/filters/PartsOneSol/filter.py b/in2lambda/filters/PartsOneSol/filter.py index b757611..efe685f 100755 --- a/in2lambda/filters/PartsOneSol/filter.py +++ b/in2lambda/filters/PartsOneSol/filter.py @@ -9,7 +9,7 @@ import panflute as pf -from in2lambda.api.module import Module +from in2lambda.api.set import Set from in2lambda.filters.markdown import filter @@ -17,7 +17,7 @@ def pandoc_filter( elem: pf.Element, doc: pf.elements.Doc, - module: Module, + set: Set, parsing_answers: bool, ) -> Optional[pf.Str]: """A Pandoc filter that parses and translates various TeX elements. @@ -26,7 +26,7 @@ def pandoc_filter( elem: The current TeX element being processed. This could be a paragraph, ordered list, etc. doc: A Pandoc document container - essentially the Pandoc AST. - module: The Python API that is used to store the result after processing + set: The Python API that is used to store the result after processing the TeX file. parsing_answers: Whether an answers-only document is currently being parsed. @@ -42,17 +42,17 @@ def pandoc_filter( isinstance(elem.prev, pf.Header) and pf.stringify(elem.prev) != "Solution" ): - module.add_question(main_text=elem) + set.add_question(main_text=elem) # Parts are denoted via ordered lists case pf.OrderedList: for item in elem.content: - module.current_question.add_part_text(item) + set.current_question.add_part_text(item) # Solution is in a Div with nested content being "Solution" case pf.Div: if pf.stringify(elem.content[0].content) == "Solution": - module.current_question.add_solution( + set.current_question.add_solution( pf.stringify(elem) # [len("Solution") :] - For Jon Rackham ) diff --git a/in2lambda/filters/PartsSepSol/filter.py b/in2lambda/filters/PartsSepSol/filter.py index c127801..5da2429 100644 --- a/in2lambda/filters/PartsSepSol/filter.py +++ b/in2lambda/filters/PartsSepSol/filter.py @@ -9,7 +9,7 @@ import panflute as pf -from in2lambda.api.module import Module +from in2lambda.api.set import Set from in2lambda.filters.markdown import filter @@ -17,7 +17,7 @@ def pandoc_filter( elem: pf.Element, doc: pf.elements.Doc, - module: Module, + set: Set, parsing_answers: bool, ) -> Optional[pf.Str]: """A Pandoc filter that parses and translates various TeX elements. @@ -26,7 +26,7 @@ def pandoc_filter( elem: The current TeX element being processed. This could be a paragraph, ordered list, etc. doc: A Pandoc document container - essentially the Pandoc AST. - module: The Python API that is used to store the result after processing + set: The Python API that is used to store the result after processing the TeX file. parsing_answers: Whether an answers-only document is currently being parsed. @@ -39,7 +39,7 @@ def pandoc_filter( for numbered_part in elem.content: if parsing_answers: # Denotes that we've reached the answer for a new question - module.increment_current_question() + set.increment_current_question() # For each numbered question, extract blurb and parts blurb: list[str] = [] lettered_parts: list[str] = [] @@ -64,17 +64,17 @@ def pandoc_filter( # Answers for questions with no parts if parsing_answers and not lettered_parts: - module.current_question.add_solution(spaced_blurb) + set.current_question.add_solution(spaced_blurb) # Add the main question text as the Lambda Feedback blurb elif not parsing_answers: - module.add_question(main_text=spaced_blurb) + set.add_question(main_text=spaced_blurb) # Add each part solution/text # For the solution, prepend any top level answer text to each part answer for part in lettered_parts: ( - module.current_question.add_solution(spaced_blurb + part) + set.current_question.add_solution(spaced_blurb + part) if parsing_answers - else module.current_question.add_part_text(part) + else set.current_question.add_part_text(part) ) return None diff --git a/in2lambda/filters/markdown.py b/in2lambda/filters/markdown.py index b285b7a..789aabd 100644 --- a/in2lambda/filters/markdown.py +++ b/in2lambda/filters/markdown.py @@ -9,7 +9,7 @@ from beartype.typing import Callable, Optional from rich_click import echo -from in2lambda.api.module import Module +from in2lambda.api.set import Set from in2lambda.katex_convert.katex_convert import latex_to_katex @@ -32,7 +32,7 @@ def image_directories(tex_file: str) -> list[str]: >>> temp_dir = tempfile.mkdtemp() >>> tex_file = os.path.join(temp_dir, 'test.tex') >>> with open(tex_file, 'w') as f: - ... f.write("\\graphicspath{{subdir1/}{subdir2/}{subdir3/}}") + ... f.write("\\graphicspath{{subdir1/}{subdir2/}{subdir3/}}") 45 >>> image_directories(tex_file) ['subdir1/', 'subdir2/', 'subdir3/'] @@ -71,7 +71,7 @@ def image_path(image_name: str, tex_file: str) -> Optional[str]: The absolute path to the image if it can be found. If not, it returns None. Examples: - >>> from in2lambda.filters.markdown import image_path + set.current_question.images.append(path) >>> import tempfile >>> import os >>> # Example TeX file with a subdirectory @@ -117,11 +117,11 @@ def image_path(image_name: str, tex_file: str) -> Optional[str]: def filter( func: Callable[ - [pf.Element, pf.elements.Doc, Module, bool], + [pf.Element, pf.elements.Doc, Set, bool], Optional[pf.Str], ] ) -> Callable[ - [pf.Element, pf.elements.Doc, Module, str, bool], + [pf.Element, pf.elements.Doc, Set, str, bool], Optional[pf.Str], ]: """Python decorator to make generic LaTeX elements markdown readable. @@ -136,7 +136,7 @@ def filter( def markdown_converter( elem: pf.Element, doc: pf.elements.Doc, - module: Module, + set: Set, tex_file: str, parsing_answers: bool, ) -> Optional[pf.Str]: @@ -175,7 +175,7 @@ def markdown_converter( if path is None: echo(f"Warning: Couldn't find {elem.url}") else: - module.current_question.images.append(path) + set.current_question.images.append(path) return pf.Str(f"![pictureTag]({elem.url})") case pf.Strong: @@ -189,6 +189,6 @@ def markdown_converter( case pf.Str: return pf.Str(elem.text.replace("\u00a0", "\u202f")) - return func(elem, doc, module, parsing_answers) + return func(elem, doc, set, parsing_answers) return markdown_converter diff --git a/in2lambda/json_convert/__init__.py b/in2lambda/json_convert/__init__.py index 39ffb4e..214a820 100644 --- a/in2lambda/json_convert/__init__.py +++ b/in2lambda/json_convert/__init__.py @@ -1 +1 @@ -"""Files relating to translating the questions of a Python module object into JSON.""" +"""Files relating to translating the questions of a Python set object into JSON.""" diff --git a/in2lambda/json_convert/json_convert.py b/in2lambda/json_convert/json_convert.py index fdb3de3..73b49eb 100644 --- a/in2lambda/json_convert/json_convert.py +++ b/in2lambda/json_convert/json_convert.py @@ -1,41 +1,78 @@ -"""Converts questions from a Python module object into Lambda Feedback JSON.""" +"""Converts questions from a Python set object into Lambda Feedback JSON.""" import json import os +import re import shutil +import zipfile from copy import deepcopy from pathlib import Path from typing import Any -from in2lambda.api.question import Question +from in2lambda.api.set import Set -MINIMAL_TEMPLATE = "minimal_template.json" +MINIMAL_QUESTION_TEMPLATE = "minimal_template_question.json" +MINIMAL_SET_TEMPLATE = "minimal_template_set.json" + + +def _zip_sorted_folder(folder_path, zip_path): + """Zips the contents of a folder, preserving the directory structure. + + Args: + folder_path: The path to the folder to zip. + zip_path: The path where the zip file will be created. + """ + with zipfile.ZipFile(zip_path, "w") as zf: + for root, dirs, files in os.walk(folder_path): + # Sort files for deterministic, alphabetical order + for file in sorted(files): + abs_path = os.path.join(root, file) + rel_path = os.path.relpath(abs_path, folder_path) + zf.write(abs_path, arcname=rel_path) def converter( - template: dict[str, Any], ListQuestions: list[Question], output_dir: str + question_template: dict[str, Any], + set_template: dict[str, Any], + SetQuestions: Set, + output_dir: str, ) -> None: - """Turns a list of question objects into Lambda Feedback JSON. + """Turns a set of question objects into Lambda Feedback JSON. Args: - template: The loaded JSON from the minimal template (it needs to be in sync). - ListQuestions: A list of question objects. + question_template: The loaded JSON from the minimal question template (it needs to be in sync). + set_template: The loaded JSON from the minimal set template (it needs to be in sync). + SetQuestions: A Set object containing questions. output_dir: The absolute path for where to produced the final JSON/zip files. """ - # Create output by copying template + ListQuestions = SetQuestions.questions + set_name = SetQuestions._name + set_description = SetQuestions._description # create directory to put the questions os.makedirs(output_dir, exist_ok=True) - output_question = os.path.join(output_dir, "set") + output_question = os.path.join(output_dir, set_name) os.makedirs(output_question, exist_ok=True) - # create directory to put images - should be in set - output_image = os.path.join(output_question, "media") - os.makedirs(output_image, exist_ok=True) + set_template["name"] = set_name + set_template["description"] = set_description + set_template["finalAnswerVisibility"] = str( + SetQuestions._finalAnswerVisibility.status + ) + set_template["workedSolutionVisibility"] = str( + SetQuestions._workedSolutionVisibility.status + ) + set_template["structuredTutorialVisibility"] = str( + SetQuestions._structuredTutorialVisibility.status + ) + # create the set file + with open(f"{output_question}/set_{set_name}.json", "w") as file: + json.dump(set_template, file) for i in range(len(ListQuestions)): - output = deepcopy(template) + output = deepcopy(question_template) + output["orderNumber"] = i # order number starts at 0 # add title to the question file if ListQuestions[i].title != "": output["title"] = ListQuestions[i].title @@ -52,7 +89,7 @@ def converter( ListQuestions[i].parts[0].worked_solution ) for j in range(1, len(ListQuestions[i].parts)): - output["parts"].append(deepcopy(template["parts"][0])) + output["parts"].append(deepcopy(question_template["parts"][0])) output["parts"][j]["content"] = ListQuestions[i].parts[j].text output["parts"][j]["orderNumber"] = j output["parts"][j]["workedSolution"]["content"] = ( @@ -60,7 +97,9 @@ def converter( ) # Output file - filename = "question_" + str(i + 1) + filename = ( + "question_" + str(i).zfill(3) + "_" + re.sub(r'[^\w\-_.]', '_', output['title'].strip()) + ) # write questions into directory with open(f"{output_question}/{filename}.json", "w") as file: @@ -71,24 +110,30 @@ def converter( image_path = os.path.abspath( ListQuestions[i].images[k] ) # converts computer path into python path + # If images exist, create a media directory + output_image = os.path.join(output_question, "media") + os.makedirs(output_image, exist_ok=True) shutil.copy(image_path, output_image) # copies image into the directory - # output zip file in destination folder - shutil.make_archive(output_question, "zip", output_question) + # output zip file in destination folder + _zip_sorted_folder(output_question, output_question + ".zip") -def main(questions: list[Question], output_dir: str) -> None: +def main(set_questions: Set, output_dir: str) -> None: """Preliminary defensive programming before calling the main converter function. This ultimately then produces the Lambda Feedback JSON/ZIP files. Args: - questions: A list of question objects. + set_questions: A Set object containing questions. output_dir: Where to output the final Lambda Feedback JSON/ZIP files. """ # Use path so minimal template can be found regardless of where the user is running python from. - with open(Path(__file__).with_name(MINIMAL_TEMPLATE), "r") as file: - template = json.load(file) + with open(Path(__file__).with_name(MINIMAL_QUESTION_TEMPLATE), "r") as file: + question_template = json.load(file) + + with open(Path(__file__).with_name(MINIMAL_SET_TEMPLATE), "r") as file: + set_template = json.load(file) # check if directory exists in file if os.path.isdir(output_dir): @@ -96,4 +141,4 @@ def main(questions: list[Question], output_dir: str) -> None: shutil.rmtree(output_dir) except OSError as e: print("Error: %s : %s" % (output_dir, e.strerror)) - converter(template, questions, output_dir) + converter(question_template, set_template, set_questions, output_dir) diff --git a/in2lambda/json_convert/minimal_template.json b/in2lambda/json_convert/minimal_template_question.json similarity index 69% rename from in2lambda/json_convert/minimal_template.json rename to in2lambda/json_convert/minimal_template_question.json index 8f12881..1f96fc8 100644 --- a/in2lambda/json_convert/minimal_template.json +++ b/in2lambda/json_convert/minimal_template_question.json @@ -1,23 +1,23 @@ { + "orderNumber": 0, + "title": "Question title here", + "masterContent": "Top level question here", + "publish": false, "displayFinalAnswer": true, "displayStructuredTutorial": true, "displayWorkedSolution": true, - "masterContent": "Top level question here", + "displayChatbot": false, "parts": [ { - "answer": "", - "content": "Part text here", "orderNumber": 0, + "content": "Part text here", + "answerContent": "", "responseAreas": [], - "tutorial": [], - "universalPartId": "N/A", "workedSolution": { + "title": "", "content": "Part worked solution here", - "id": "N/A", - "title": "" + "children": [] } } - ], - "publish": false, - "title": "Question title here" + ] } diff --git a/in2lambda/json_convert/minimal_template_set.json b/in2lambda/json_convert/minimal_template_set.json new file mode 100644 index 0000000..f7b4655 --- /dev/null +++ b/in2lambda/json_convert/minimal_template_set.json @@ -0,0 +1,9 @@ +{ + "name": "a simple example", + "description": "description here", + "manuallyHidden": true, + "finalAnswerVisibility": "OPEN_WITH_WARNINGS", + "workedSolutionVisibility": "OPEN_WITH_WARNINGS", + "structuredTutorialVisibility": "OPEN", + "chatbotVisibility": "HIDE" +} \ No newline at end of file diff --git a/in2lambda/main.py b/in2lambda/main.py index c822806..d5df1e2 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -15,7 +15,7 @@ import rich_click as click import in2lambda.filters -from in2lambda.api.module import Module +from in2lambda.api.set import Set def docx_to_md(docx_file: str) -> str: @@ -81,7 +81,7 @@ def runner( chosen_filter: str, output_dir: Optional[str] = None, answer_file: Optional[str] = None, -) -> Module: +) -> Set: r"""Takes in a TeX file for a given subject and outputs how it's broken down within Lambda Feedback. Args: @@ -100,12 +100,12 @@ def runner( >>> from in2lambda.main import runner >>> # Retrieve an example TeX file and run the given filter. >>> runner(f"{os.path.dirname(in2lambda.__file__)}/filters/PartsSepSol/example.tex", "PartsSepSol") # doctest: +ELLIPSIS - Module(questions=[Question(title='', parts=[Part(text=..., worked_solution=''), ...], images=[], main_text='This is a sample question\n\n'), ...]) + Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', questions=[Question(title='', parts=[Part(text=..., worked_solution=''), ...], images=[], main_text='This is a sample question\n\n'), ...]) >>> runner(f"{os.path.dirname(in2lambda.__file__)}/filters/PartsOneSol/example.tex", "PartsOneSol") # doctest: +ELLIPSIS - Module(questions=[Question(title='', parts=[Part(text='This is part (a)\n\n', worked_solution=''), ...], images=[], main_text='Here is some preliminary question information that might be useful.'), ...) + Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', questions=[Question(title='', parts=[Part(text=..., worked_solution=''), ...], images=[], main_text='Here is some preliminary question information that might be useful.'), ...]) """ # The list of questions for Lambda Feedback as a Python API. - module = Module() + set_obj = Set() # Dynamically import the correct pandoc filter depending on the subject. filter_module = importlib.import_module(f"in2lambda.filters.{chosen_filter}.filter") @@ -124,7 +124,7 @@ def runner( pf.run_filter( filter_module.pandoc_filter, doc=pf.convert_text(text, input_format=input_format, standalone=True), - module=module, + set=set_obj, tex_file=question_file, parsing_answers=False, ) @@ -146,16 +146,16 @@ def runner( doc=pf.convert_text( answer_text, input_format=answer_format, standalone=True ), - module=module, + set=set_obj, tex_file=answer_file, parsing_answers=True, ) # Read the Python API format and convert to JSON. if output_dir is not None: - module.to_json(output_dir) + set_obj.to_json(output_dir) - return module + return set_obj @click.command( diff --git a/poetry.lock b/poetry.lock index fe35d91..aadca6b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -934,13 +934,13 @@ files = [ [[package]] name = "requests" -version = "2.32.0" +version = "2.31.0" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" files = [ - {file = "requests-2.32.0-py3-none-any.whl", hash = "sha256:f2c3881dddb70d056c5bd7600a4fae312b2a300e39be6a118d30b90bd27262b5"}, - {file = "requests-2.32.0.tar.gz", hash = "sha256:fa5490319474c82ef1d2c9bc459d3652e3ae4ef4c4ebdd18a21145a47ca4b6b8"}, + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, ] [package.dependencies] @@ -1386,4 +1386,4 @@ test = ["pytest (>=6.0.0)", "setuptools (>=65)"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "41ccb63d33d5347e6403ef8d282525f720fe57bebc7097d088c9e1586cfd6f23" +content-hash = "1936b6f3f9ddf7bd069e1fba61aa4e60e990e82aef7c4fb3c3653fd9f53fbbc8" diff --git a/pyproject.toml b/pyproject.toml index 6c89981..9e18407 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ python = "^3.10" panflute = "^2.3.1" rich-click = "^1.7.4" beartype = "^0.17.2" +requests = "<2.32.0" [tool.poetry.scripts] in2lambda = "in2lambda.main:cli" @@ -56,6 +57,9 @@ sphinx-togglebutton = "^0.3.2" # panflute is missing type hints ignore_missing_imports = true +[tool.isort] +profile = "black" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api"