From 5a8414fa2cf666bacf207706aaabc7e80f1ced15 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Mon, 31 Aug 2026 12:03:38 +0100 Subject: [PATCH] refactor!: make in2lambda a command group (convert) BREAKING CHANGE: file conversion moves from `in2lambda ` to `in2lambda convert `. `cli` is now a click.group so further subcommands (a wizard, etc.) can be added without overloading the top-level command. The former command body is unchanged, just relocated to `convert`; `runner()` is untouched. - pyproject `[tool.poetry.scripts]` and docs/source/reference/command-line.rst still point at `in2lambda.main:cli` - no change needed there, sphinx-click renders the subcommand automatically. - quickstart examples updated to `in2lambda convert ...` with a note. - tests/test_cli.py added (CliRunner) covering help, output files, case-insensitive filter names, and unknown-filter rejection. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017VXb8aZqgFBjoeuuddjW6r --- docs/source/quickstart.md | 12 ++++++--- in2lambda/main.py | 13 ++++++--- tests/test_cli.py | 57 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 tests/test_cli.py diff --git a/docs/source/quickstart.md b/docs/source/quickstart.md index 194f94e..3d273c4 100644 --- a/docs/source/quickstart.md +++ b/docs/source/quickstart.md @@ -56,9 +56,15 @@ A list of available filters can be found [here](filters/index). For instance, the following takes in `questions.tex` and uses a filter that expects [each part to be directly followed by the solution](filters/_autosummary/PartSolPartSol): ```bash -$ in2lambda questions.tex PartSolPartSol +$ in2lambda convert questions.tex PartSolPartSol ``` +:::{important} +File conversion lives under the `convert` subcommand (`in2lambda convert ...`, +not `in2lambda ...`). This leaves room for other subcommands, such as one that +turns unstructured documents into markdown. +::: + :::{note} The filter name is case-insensitive. Don't worry about the capital letters. ::: @@ -66,13 +72,13 @@ The filter name is case-insensitive. Don't worry about the capital letters. Another filter might be used if [the answers are in a separate file](filters/_autosummary/PartsSepSol): ```bash -$ in2lambda questions.tex -a solutions.tex PartsSepSol +$ in2lambda convert questions.tex -a solutions.tex PartsSepSol ``` If you would rather write the questions yourself, the [`Markdown` filter](filters/_autosummary/Markdown) reads a plain markdown file where `#` starts a question, `##` starts a part, and `## Solution` gives a worked solution: ```bash -$ in2lambda questions.md Markdown +$ in2lambda convert questions.md Markdown ``` By default, this generates an `out` directory in the same place that the command was run in. It contains the zipped question files. diff --git a/in2lambda/main.py b/in2lambda/main.py index 0aeb664..400b302 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -171,10 +171,15 @@ def runner( return set_obj -@click.command( +@click.group( no_args_is_help=True, epilog="See the docs at https://lambda-feedback.github.io/in2lambda/ for more details.", ) +def cli() -> None: + """Convert documents into Lambda Feedback compatible question sets.""" + + +@cli.command(no_args_is_help=True) @click.argument( # Use resolve_path to get absolute path "question_file", type=click.Path(exists=True, readable=True, resolve_path=True) ) @@ -207,11 +212,11 @@ def runner( help="File containing solutions for QUESTION_FILE.", type=click.Path(resolve_path=True, exists=True, dir_okay=False), ) -def cli( +def convert( question_file: str, chosen_filter: str, output_dir: str, answer_file: Optional[str] ) -> 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. + """Take a QUESTION_FILE and CHOSEN_FILTER and produce Lambda Feedback json/zip files.""" + # Kept separate from runner() so runner() can be imported as part of the library. runner(question_file, chosen_filter, output_dir, answer_file) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..28ff15d --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,57 @@ +"""Tests for the ``in2lambda`` command-line interface (the ``convert`` subcommand).""" + +import os + +from click.testing import CliRunner + +from in2lambda.main import cli + + +def test_bare_invocation_shows_usage() -> None: + result = CliRunner().invoke(cli, []) + assert "Usage:" in result.output + assert "convert" in result.output + + +def test_help_lists_the_convert_command() -> None: + result = CliRunner().invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "convert" in result.output + + +def test_convert_writes_output_files(filters_dir: str, tmp_path) -> None: + example = os.path.join(filters_dir, "PartsSepSol", "example.tex") + out_dir = tmp_path / "out" + + result = CliRunner().invoke( + cli, ["convert", example, "PartsSepSol", "-o", str(out_dir)] + ) + + assert result.exit_code == 0, result.output + assert (out_dir / "set").is_dir() + assert (out_dir / "set.zip").is_file() + + +def test_convert_accepts_case_insensitive_filter_and_markdown( + filters_dir: str, tmp_path +) -> None: + example = os.path.join(filters_dir, "Markdown", "example.md") + out_dir = tmp_path / "out" + + result = CliRunner().invoke( + cli, ["convert", example, "markdown", "-o", str(out_dir)] + ) + + assert result.exit_code == 0, result.output + assert (out_dir / "set" / "set_set.json").is_file() + + +def test_convert_rejects_unknown_filter(filters_dir: str, tmp_path) -> None: + example = os.path.join(filters_dir, "PartsSepSol", "example.tex") + + result = CliRunner().invoke( + cli, ["convert", example, "NotAFilter", "-o", str(tmp_path / "out")] + ) + + assert result.exit_code != 0 + assert "NotAFilter" in result.output