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 c148e1e..f51cbef 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -171,10 +171,38 @@ def runner( return set_obj -@click.command( +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: + """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 +235,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/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 new file mode 100644 index 0000000..c57f1b7 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,87 @@ +"""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 + + +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