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
12 changes: 9 additions & 3 deletions docs/source/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,23 +56,29 @@ 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.
:::

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.
Expand Down
36 changes: 32 additions & 4 deletions in2lambda/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Expand Down Expand Up @@ -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)


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
87 changes: 87 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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 <file> <filter>` 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
Loading