Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deploy-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
python -m pip install --upgrade pip setuptools wheel virtualenv
python -m pip install "virtualenv<20.26.0" poetry
poetry config virtualenvs.create false
poetry install
poetry install --all-extras
- id: deployment
uses: sphinx-notes/pages@v3
with:
Expand Down
27 changes: 26 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
python -m pip install --upgrade setuptools wheel
pip install poetry
poetry config virtualenvs.create true
poetry install --with dev
poetry install --with dev --all-extras
- name: Install Pandoc # apt version seems too old
uses: r-lib/actions/setup-pandoc@v2
- name: Linting Checks
Expand All @@ -34,3 +34,28 @@ jobs:
with:
file: ./coverage.xml
flags: unittests

# The build job always has panflute and pandoc; this proves the library works without them.
library-only:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install without extras
run: pip install .
- name: Author a set
working-directory: ${{ runner.temp }}
run: |
python -c "
import tempfile
from in2lambda.api.set import Set
s = Set()
s.add_question('Q', 'text')
s.current_question.add_part_text('part a')
s.current_question.add_solution('solution a')
s.to_json(tempfile.mkdtemp())
"
in2lambda --help
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ WORKDIR /app
COPY ./pyproject.toml ./README.md ./in2lambda /app/
COPY ./in2lambda /app/in2lambda/

RUN pip install .
RUN pip install '.[convert]'

FROM python:3.11.4-alpine

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ in2lambda is a Python command line tool and library that automagically uploads q
Find out more in the [documentation](https://lambda-feedback.github.io/in2lambda/).

```
$ pip install in2lambda
$ pip install in2lambda # Python library
$ pip install 'in2lambda[convert]' # also convert documents (needs pandoc)
```
4 changes: 2 additions & 2 deletions docs/source/contributing/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The project can then be installed using [poetry](https://python-poetry.org/):
(make sure you are in the top folder, which is the folder that contains pyproject.toml file)

```shell
$ poetry install
$ poetry install --all-extras
$ poetry shell
$ pre-commit install
$ in2lambda --help
Expand All @@ -41,7 +41,7 @@ $ source env/bin/activate
Then install in [editable mode](https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs):

```shell
$ pip install -e .
$ pip install -e '.[convert]'
$ in2lambda --help
```
:::
12 changes: 7 additions & 5 deletions docs/source/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,16 @@ The container is stopped and deleted after exiting, although the image remains d
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/in2lambda?style=flat-square&logo=python&logoColor=white)](https://pypi.org/project/in2lambda/)


:::{important}
Ensure that [pandoc](https://pandoc.org/installing.html) is already installed.
:::

in2lambda can be installed via [pip](https://pip.pypa.io/en/stable/):
in2lambda can be installed via [pip](https://pip.pypa.io/en/stable/). To author questions in Python:

```shell
$ pip install in2lambda
```

To convert documents, install the `convert` extra and [pandoc](https://pandoc.org/installing.html):

```shell
$ pip install 'in2lambda[convert]'
$ in2lambda --help
```

Expand Down
10 changes: 8 additions & 2 deletions in2lambda/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@

import beartype
import click
import panflute
import rich_click
from beartype.claw import beartype_this_package
from rich.traceback import install

beartype_this_package()
# TODO: Automate suppresion list for third party modules
# See: https://rich.readthedocs.io/en/stable/traceback.html#suppressing-frames
install(show_locals=True, suppress=[panflute, click, rich_click, beartype])
_suppress = [click, rich_click, beartype]
try: # panflute is only installed with the convert extra.
import panflute

_suppress.append(panflute)
except ImportError:
pass
install(show_locals=True, suppress=_suppress)
40 changes: 25 additions & 15 deletions in2lambda/api/question.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
"""A full question with optional parts that's contained in a set."""

from dataclasses import dataclass, field
from typing import Union

import panflute as pf
from typing import Any

from in2lambda.api.part import Part


def _as_text(value: Any, newlines: bool = True) -> str:
# panflute is an optional extra, so it is only imported for a caller who
# passed something other than a string, which should be a panflute element.
if isinstance(value, str):
return value
try:
import panflute as pf
except ImportError:
pf = None
if pf is None or not isinstance(value, pf.Element):
raise TypeError("expected a string or a panflute element")
return pf.stringify(value, newlines)


@dataclass
class Question:
"""A full question as represented on Lambda Feedback.
Expand Down Expand Up @@ -54,34 +66,32 @@ def main_text(self) -> str:
return self._main_text

@main_text.setter
def main_text(self, value: Union[pf.Element, str, property]) -> None:
def main_text(self, value: Any) -> None:
r"""Appends to the top-level main text, which starts off as an empty string.

Args:
value: A panflute element or string denoting what to append to the main text.
value: A string, or a panflute element, denoting what to append to the main text.

See example in main_text property.
"""
# Converts the inputted value into a string, stored in text_value
match value:
case str():
text_value = value
case property():
# Use default value when no value set at initialisation.
# See https://stackoverflow.com/a/61480946
text_value = self.main_text
case pf.Element():
text_value = pf.stringify(value, False)
case _:
text_value = _as_text(value, newlines=False)

if self._main_text:
self._main_text += "\n"
self._main_text += text_value

def add_solution(self, elem: Union[pf.Element, str]) -> None:
def add_solution(self, elem: Any) -> None:
"""Adds a worked solution to all question parts without one, or inserts a new empty part with the solution if all parts already have a solution.

Args:
elem: A string or panflute element denoting a worked solution.
elem: A string, or a panflute element, denoting a worked solution.

Examples:
>>> from in2lambda.api.question import Question
Expand All @@ -104,7 +114,7 @@ def add_solution(self, elem: Union[pf.Element, str]) -> None:
Part(text='part b', worked_solution='Solution for b'), \
Part(text='part c', worked_solution='We now have a solution for c!')], images=[], main_text='')
"""
elem_text = elem if isinstance(elem, str) else pf.stringify(elem)
elem_text = _as_text(elem)

# If all parts have a distinct solution, add an empty part with the solution
# This is useful if the solutions arrive before the part text in the filter.
Expand All @@ -119,11 +129,11 @@ def add_solution(self, elem: Union[pf.Element, str]) -> None:

self._last_part["solution"] += 1

def add_part_text(self, elem: Union[pf.Element, str]) -> None:
def add_part_text(self, elem: Any) -> None:
"""Either adds a new part with the given text or modifies the first part with no text.

Args:
elem: A string or panflute element denoting what the part text should be.
elem: A string, or a panflute element, denoting what the part text should be.

Examples:
>>> from in2lambda.api.question import Question
Expand All @@ -139,7 +149,7 @@ def add_part_text(self, elem: Union[pf.Element, str]) -> None:
Question(title='', parts=[Part(text='part a', worked_solution='part a solution'), \
Part(text='part b', worked_solution='part b solution')], images=[], main_text='')
"""
elem_text = elem if isinstance(elem, str) else pf.stringify(elem)
elem_text = _as_text(elem)

if len(self.parts) == self._last_part["text"]:
self.parts.append(Part(text=elem_text))
Expand Down
13 changes: 4 additions & 9 deletions in2lambda/api/set.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
"""Represents a list of questions."""

from dataclasses import dataclass, field
from typing import Union

import panflute as pf
from typing import Any

from in2lambda.api.question import Question
from in2lambda.api.visibility_status import VisibilityController, VisibilityStatus
Expand Down Expand Up @@ -57,21 +55,18 @@ def current_question(self) -> Question:
else Question("INVALID")
)

def add_question(
self, title: str = "", main_text: Union[pf.Element, str] = pf.Str("")
) -> None:
def add_question(self, title: str = "", main_text: Any = "") -> 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.
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.add_question("Some title", "hello there")
>>> s.questions
[Question(title='Some title', parts=[], images=[], main_text='hello there')]
>>> s.add_question(main_text="Normal string text")
Expand Down
31 changes: 29 additions & 2 deletions in2lambda/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,34 @@
# sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))

import importlib
import importlib.util
import pkgutil
import shutil
import subprocess
from typing import Optional

import panflute as pf
import rich_click as click

import in2lambda.filters
from in2lambda.api.set import Set


class ConversionToolsMissing(RuntimeError):
"""Document conversion was asked for without pandoc or panflute installed."""


def _require_conversion_tools() -> None:
missing = []
if shutil.which("pandoc") is None:
missing.append("pandoc (see https://pandoc.org/installing.html)")
if importlib.util.find_spec("panflute") is None:
missing.append("panflute (pip install 'in2lambda[convert]')")
if missing:
raise ConversionToolsMissing(
f"Converting documents needs {' and '.join(missing)}."
)


def docx_to_md(docx_file: str) -> str:
"""Converts .docx files to markdown.

Expand Down Expand Up @@ -95,6 +112,9 @@ def runner(
in a Python-readable format. If `output_dir` is specified, the corresponding json/zip files are
produced.

Raises:
ConversionToolsMissing: pandoc or panflute is not installed.

Examples:
>>> import os
>>> from in2lambda.main import runner
Expand All @@ -104,6 +124,9 @@ def runner(
>>> runner(f"{os.path.dirname(in2lambda.__file__)}/filters/PartsOneSol/example.tex", "PartsOneSol") # doctest: +ELLIPSIS
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.'), ...])
"""
_require_conversion_tools()
import panflute as pf

# The list of questions for Lambda Feedback as a Python API.
set_obj = Set()

Expand Down Expand Up @@ -199,7 +222,11 @@ def cli(
) -> None:
"""Takes in a QUESTION_FILE for a given SUBJECT and produces Lambda Feedback compatible json/zip files."""
# main() is made separate from click() so that it can be easily imported as part of a library.
runner(question_file, chosen_filter, output_dir, answer_file)
try:
runner(question_file, chosen_filter, output_dir, answer_file)
except ConversionToolsMissing as error:
# Exit with the install instructions rather than a traceback.
raise click.ClickException(str(error)) from None


if __name__ == "__main__":
Expand Down
15 changes: 10 additions & 5 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading