From 5a8414fa2cf666bacf207706aaabc7e80f1ced15 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Mon, 31 Aug 2026 12:03:38 +0100 Subject: [PATCH 1/2] 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 From 8b67a9389687f2b7dd966007c2cbcab72c7fc34c Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 15 Sep 2026 21:08:26 +0100 Subject: [PATCH 2/2] Added error handling for when a command is not recognized --- in2lambda/main.py | 23 +++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_cli.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/in2lambda/main.py b/in2lambda/main.py index 400b302..e226bf1 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -171,8 +171,31 @@ def runner( return set_obj +class _Cli(click.RichGroup): + """A group that always errors on an unresolved subcommand. + + Click's default resolution silently exits 0 (printing help) instead of + erroring when the unresolved name starts with a non-alphanumeric + character (e.g. an absolute or ./relative path), because it mistakes + the token for a global option and re-parses remaining args. Skip that + so every unrecognised command fails loudly with a pointer to `convert`. + """ + + def resolve_command(self, ctx, args): # type: ignore[override] + cmd_name = str(args[0]) + cmd = self.get_command(ctx, cmd_name) + if cmd is None: + ctx.fail( + f"No such command {cmd_name!r}.\n\n" + "As of in2lambda 2.0.0, conversion requires the `convert` " + f"subcommand, e.g.:\n in2lambda convert {' '.join(args)}" + ) + return cmd_name, cmd, args[1:] + + @click.group( no_args_is_help=True, + cls=_Cli, epilog="See the docs at https://lambda-feedback.github.io/in2lambda/ for more details.", ) def cli() -> None: diff --git a/pyproject.toml b/pyproject.toml index e1b4022..a1d061e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "in2lambda" -version = "1.0.0" +version = "2.0.0" description = "Converts content ready for import into Lambda Feedback" authors = [] license = "MIT" diff --git a/tests/test_cli.py b/tests/test_cli.py index 28ff15d..c57f1b7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -55,3 +55,33 @@ def test_convert_rejects_unknown_filter(filters_dir: str, tmp_path) -> None: assert result.exit_code != 0 assert "NotAFilter" in result.output + + +def test_old_style_absolute_path_invocation_fails(filters_dir: str) -> None: + """The pre-2.0.0 `in2lambda ` form must error, not no-op. + + Click's default command resolution silently exits 0 here instead of + erroring, because it mistakes a leading "/" for an option prefix. + """ + example = os.path.join(filters_dir, "PartsSepSol", "example.tex") + assert os.path.isabs(example) + + result = CliRunner().invoke(cli, [example, "PartsSepSol"]) + + assert result.exit_code != 0 + assert "convert" in result.output + + +def test_old_style_dot_relative_path_invocation_fails(filters_dir: str) -> None: + """Same as above but for a `./relative` path, which hits the same bug.""" + result = CliRunner().invoke(cli, ["./example.tex", "PartsSepSol"]) + + assert result.exit_code != 0 + assert "convert" in result.output + + +def test_old_style_bare_filename_invocation_fails() -> None: + result = CliRunner().invoke(cli, ["example.tex", "PartsSepSol"]) + + assert result.exit_code != 0 + assert "convert" in result.output