From 2ca503917c765dceae83099dc2c99be2e4bd49ec Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Tue, 15 Sep 2026 21:58:07 +0100 Subject: [PATCH 1/2] implement: Make pandoc and the filters optional (t9) --- .github/workflows/test.yml | 27 +++++++++++++- Dockerfile | 2 +- README.md | 3 +- docs/source/contributing/installation.md | 4 +-- docs/source/quickstart.md | 12 ++++--- in2lambda/__init__.py | 10 ++++-- in2lambda/api/question.py | 40 +++++++++++++-------- in2lambda/api/set.py | 13 +++---- in2lambda/main.py | 31 ++++++++++++++-- poetry.lock | 15 +++++--- pyproject.toml | 6 +++- tests/test_api_without_panflute.py | 32 +++++++++++++++++ tests/test_conversion_tools.py | 46 ++++++++++++++++++++++++ 13 files changed, 197 insertions(+), 44 deletions(-) create mode 100644 tests/test_api_without_panflute.py create mode 100644 tests/test_conversion_tools.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d2e798f..0d93cef 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 @@ -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 diff --git a/Dockerfile b/Dockerfile index 1e1e0fa..b3fe63b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index b91c632..2fc6d6b 100644 --- a/README.md +++ b/README.md @@ -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) ``` diff --git a/docs/source/contributing/installation.md b/docs/source/contributing/installation.md index a0714ca..005dd18 100644 --- a/docs/source/contributing/installation.md +++ b/docs/source/contributing/installation.md @@ -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 @@ -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 ``` ::: diff --git a/docs/source/quickstart.md b/docs/source/quickstart.md index 34fd175..cdd51b7 100644 --- a/docs/source/quickstart.md +++ b/docs/source/quickstart.md @@ -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 ``` diff --git a/in2lambda/__init__.py b/in2lambda/__init__.py index fde640b..36e7095 100644 --- a/in2lambda/__init__.py +++ b/in2lambda/__init__.py @@ -2,7 +2,6 @@ import beartype import click -import panflute import rich_click from beartype.claw import beartype_this_package from rich.traceback import install @@ -10,4 +9,11 @@ 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) diff --git a/in2lambda/api/question.py b/in2lambda/api/question.py index 6ee42d4..b9d127c 100644 --- a/in2lambda/api/question.py +++ b/in2lambda/api/question.py @@ -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. @@ -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 @@ -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. @@ -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 @@ -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)) diff --git a/in2lambda/api/set.py b/in2lambda/api/set.py index 58c7a52..588a637 100644 --- a/in2lambda/api/set.py +++ b/in2lambda/api/set.py @@ -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 @@ -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") diff --git a/in2lambda/main.py b/in2lambda/main.py index d5df1e2..e95daf2 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -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. @@ -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 @@ -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() @@ -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__": diff --git a/poetry.lock b/poetry.lock index c480e8d..9226b7f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. [[package]] name = "alabaster" @@ -561,7 +561,7 @@ files = [ beautifulsoup4 = "*" pygments = ">=2.7" sphinx = ">=6.0,<9.0" -sphinx-basic-ng = ">=1.0.0b2" +sphinx-basic-ng = ">=1.0.0.beta2" [[package]] name = "identify" @@ -1061,7 +1061,7 @@ sphinx = ">=6,<8" [package.extras] code-style = ["pre-commit (>=3.0,<4.0)"] linkify = ["linkify-it-py (>=2.0,<3.0)"] -rtd = ["ipython", "pydata-sphinx-theme (==0.13.0rc4)", "sphinx-autodoc2 (>=0.4.2,<0.5.0)", "sphinx-book-theme (==1.0.0rc2)", "sphinx-copybutton", "sphinx-design2", "sphinx-pyscript", "sphinx-tippy (>=0.3.1)", "sphinx-togglebutton", "sphinxext-opengraph (>=0.8.2,<0.9.0)", "sphinxext-rediraffe (>=0.2.7,<0.3.0)"] +rtd = ["ipython", "pydata-sphinx-theme (==v0.13.0rc4)", "sphinx-autodoc2 (>=0.4.2,<0.5.0)", "sphinx-book-theme (==1.0.0rc2)", "sphinx-copybutton", "sphinx-design2", "sphinx-pyscript", "sphinx-tippy (>=0.3.1)", "sphinx-togglebutton", "sphinxext-opengraph (>=0.8.2,<0.9.0)", "sphinxext-rediraffe (>=0.2.7,<0.3.0)"] testing = ["beautifulsoup4", "coverage[toml]", "pytest (>=7,<8)", "pytest-cov", "pytest-param-files (>=0.3.4,<0.4.0)", "pytest-regressions", "sphinx-pytest"] testing-docutils = ["pygments", "pytest (>=7,<8)", "pytest-param-files (>=0.3.4,<0.4.0)"] @@ -1093,9 +1093,10 @@ files = [ name = "panflute" version = "2.3.1" description = "Pythonic Pandoc filters" -optional = false +optional = true python-versions = ">=3.7" groups = ["main"] +markers = "extra == \"convert\"" files = [ {file = "panflute-2.3.1-py3-none-any.whl", hash = "sha256:e44afd875b7b17ffebbbe58282849df06d9f1b20a45a2f933cd51bdcf4e89130"}, {file = "panflute-2.3.1.tar.gz", hash = "sha256:5f1bd02a34ef3982ee025ec5b58fb3a6eedfc31d994b8ae39d8dc9915a2d8f1f"}, @@ -1421,6 +1422,7 @@ files = [ {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] +markers = {main = "extra == \"convert\""} [[package]] name = "requests" @@ -1948,7 +1950,10 @@ files = [ [package.dependencies] packaging = ">=24.0" +[extras] +convert = ["panflute"] + [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "df980786fb1dc46f21802c0347b960faac783f7746f546f8591f610b6ebd1ee2" +content-hash = "86955610a6b9c6393750473c1ca149ed1a7e557a5765869f67d8b885ab7f44a2" diff --git a/pyproject.toml b/pyproject.toml index 6566e11..66f7053 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,11 +23,15 @@ documentation = "https://lambda-feedback.github.io/in2lambda" [tool.poetry.dependencies] python = "^3.10" -panflute = "^2.3.1" +panflute = { version = "^2.3.1", optional = true } rich-click = "^1.7.4" beartype = "^0.17.2" requests = "<2.34.0" +[tool.poetry.extras] +# Only needed to convert documents; the Python API works without it. +convert = ["panflute"] + [tool.poetry.scripts] in2lambda = "in2lambda.main:cli" diff --git a/tests/test_api_without_panflute.py b/tests/test_api_without_panflute.py new file mode 100644 index 0000000..f2b6a18 --- /dev/null +++ b/tests/test_api_without_panflute.py @@ -0,0 +1,32 @@ +"""The Python API authors and exports a set without panflute installed. + +panflute is only in the ``convert`` extra. The test runs in a fresh interpreter +with panflute blocked, so a module-level import anywhere on the API path fails it +even though the development environment has panflute. +""" + +import json +import subprocess +import sys + +SCRIPT = """ +import sys +sys.modules["panflute"] = None # makes `import panflute` raise ImportError + +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(sys.argv[1]) +""" + + +def test_api_works_without_panflute(tmp_path) -> None: + """A set with a part and a solution is written to JSON without panflute.""" + subprocess.run([sys.executable, "-c", SCRIPT, str(tmp_path)], check=True) + + question = json.loads((tmp_path / "set" / "question_000_Q.json").read_text()) + assert question["title"] == "Q" + assert len(question["parts"]) == 1 diff --git a/tests/test_conversion_tools.py b/tests/test_conversion_tools.py new file mode 100644 index 0000000..65bcf29 --- /dev/null +++ b/tests/test_conversion_tools.py @@ -0,0 +1,46 @@ +"""Converting a document without pandoc or panflute says what to install.""" + +import os +import shutil +import sys + +import pytest +from click.testing import CliRunner + +from in2lambda.main import ConversionToolsMissing, cli, runner + +PANDOC_HINT = "pandoc.org/installing" +PANFLUTE_HINT = "pip install 'in2lambda[convert]'" + + +@pytest.mark.parametrize( + "pandoc_missing, panflute_missing", + [(True, True), (True, False), (False, True)], +) +def test_runner_names_what_is_missing( + pandoc_missing: bool, panflute_missing: bool, monkeypatch, tmp_path +) -> None: + """The error names each missing tool, and only those.""" + if pandoc_missing: + monkeypatch.setattr(shutil, "which", lambda _: None) + if panflute_missing: + monkeypatch.setitem(sys.modules, "panflute", None) + + # The file does not exist, so reading it first would raise FileNotFoundError. + with pytest.raises(ConversionToolsMissing) as error: + runner(str(tmp_path / "missing.tex"), "PartsSepSol") + + assert (PANDOC_HINT in str(error.value)) == pandoc_missing + assert (PANFLUTE_HINT in str(error.value)) == panflute_missing + + +def test_cli_exits_with_message(filters_dir: str, monkeypatch, tmp_path) -> None: + """The command line prints the install instructions, not a traceback.""" + monkeypatch.setitem(sys.modules, "panflute", None) + example = os.path.join(filters_dir, "PartsSepSol", "example.tex") + + result = CliRunner().invoke(cli, [example, "PartsSepSol", "-o", str(tmp_path)]) + + assert result.exit_code != 0 + assert PANFLUTE_HINT in result.output + assert "Traceback" not in result.output From cc16c1d7b93b5c3a1c6cec8ee5260b985d88263e Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Tue, 15 Sep 2026 22:02:54 +0100 Subject: [PATCH 2/2] implement: Make pandoc and the filters optional (t9) --- .github/workflows/deploy-docs.yml | 2 +- tests/test_conversion_tools.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 331480e..8614cd0 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -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: diff --git a/tests/test_conversion_tools.py b/tests/test_conversion_tools.py index 65bcf29..03d9ff2 100644 --- a/tests/test_conversion_tools.py +++ b/tests/test_conversion_tools.py @@ -43,4 +43,4 @@ def test_cli_exits_with_message(filters_dir: str, monkeypatch, tmp_path) -> None assert result.exit_code != 0 assert PANFLUTE_HINT in result.output - assert "Traceback" not in result.output + assert isinstance(result.exception, SystemExit)