Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Changelog

## 2.0.0

- Converting a document is now `in2lambda convert FILE FILTER`, with the same options as before (`-o/--out`, `-a/--answers`). Scripts and Docker invocations that run `in2lambda FILE FILTER` need the extra word.
- `in2lambda FILE FILTER` exits with an error naming the command to run instead, rather than printing its usage and exiting successfully.
- beartype is now `^0.22`. At 0.20.0 and below its import hook leaves `cli` a plain function rather than a group, so the new command line either fails to import or runs `convert` whatever the arguments; 0.20.1 is the first version that works.
- The Python API is unchanged: `in2lambda.main.runner` and everything under `in2lambda.api` take the same arguments and return the same objects.
2 changes: 2 additions & 0 deletions docs/source/contributing/new_version.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ name = "in2lambda"
version = "0.2.1" # <-- This line here
```

and add an entry to [`CHANGELOG.md`](https://github.com/lambda-feedback/in2lambda/blob/main/CHANGELOG.md) saying what changed for anyone using the command line or the Python API.

2) Create a new [tag](https://git-scm.com/book/en/v2/Git-Basics-Tagging) for the docker image to be built. For instance, to tag a version v1.2.3:

```shell
Expand Down
6 changes: 3 additions & 3 deletions docs/source/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ This can also be done through [pipx](https://pypa.github.io/pipx/).

## 2. Choose a Document

in2lambda takes in two arguments:
`in2lambda convert` takes in two arguments:

- The path to a document.
- A filter describing how to parse it.
Expand All @@ -58,7 +58,7 @@ 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
```

:::{note}
Expand All @@ -68,7 +68,7 @@ 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
```

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
29 changes: 27 additions & 2 deletions in2lambda/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import importlib
import importlib.util
import shlex
import shutil
import subprocess
from typing import Optional
Expand Down Expand Up @@ -185,10 +186,34 @@ def runner(
return set_obj


@click.command(
class _Cli(click.RichGroup):
"""The in2lambda group, which says what to run when given the pre-2.0 command line."""

def resolve_command(self, ctx, args): # type: ignore[no-untyped-def]
"""Fail with the new command line rather than click's handling of an unknown name.

Click resolves a first argument starting with ``/`` or ``.`` by printing the
group's help and exiting successfully, so `in2lambda /path/to/questions.tex
PartsSepSol` would look like it had worked while converting nothing.
"""
# Shell completion resolves partial command lines, and must not raise.
if not ctx.resilient_parsing and self.get_command(ctx, args[0]) is None:
raise click.UsageError(
f"in2lambda no longer takes a file directly. Run: in2lambda convert {shlex.join(args)}"
)
return super().resolve_command(ctx, args)


@click.group(
cls=_Cli,
no_args_is_help=True,
epilog="See the docs at https://lambda-feedback.github.io/in2lambda/ for more details.",
)
def cli() -> None:
"""Prepares content for import into Lambda Feedback."""


@cli.command()
@click.argument( # Use resolve_path to get absolute path
"question_file", type=click.Path(exists=True, readable=True, resolve_path=True)
)
Expand All @@ -214,7 +239,7 @@ 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."""
Expand Down
21 changes: 11 additions & 10 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions 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 All @@ -25,7 +25,11 @@ documentation = "https://lambda-feedback.github.io/in2lambda"
python = "^3.10"
panflute = { version = "^2.3.1", optional = true }
rich-click = "^1.7.4"
beartype = "^0.17.2"
# At 0.20.0 and below, the import hook in in2lambda/__init__.py leaves `cli` a plain
# function: `@cli.command()` then raises AttributeError at import (0.19.0, 0.20.0), or
# `in2lambda ...` runs convert's body whatever the arguments (0.18.5). 0.20.1 is the
# first version that works; 0.22 is the series this is locked to and tested against.
beartype = "^0.22"

[tool.poetry.extras]
# Only needed to convert documents; the Python API works without it.
Expand Down
88 changes: 88 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""What the command line does with the current and the pre-2.0 form."""

import os
import shutil
import subprocess
import sys

import pytest
from click.shell_completion import ShellComplete
from click.testing import CliRunner

from in2lambda.main import cli


def test_convert_writes_the_set(filters_dir: str, tmp_path) -> None:
"""`in2lambda convert` reaches the runner and produces the zipped set."""
example = os.path.join(filters_dir, "PartsSepSol", "example.tex")

result = CliRunner().invoke(
cli, ["convert", example, "PartsSepSol", "-o", str(tmp_path)]
)

assert result.exit_code == 0, result.output
assert (tmp_path / "set.zip").exists()


@pytest.mark.parametrize("path", ["example.tex", "./example.tex", "ABSOLUTE"])
def test_old_form_fails_and_names_convert(
path: str, filters_dir: str, monkeypatch, tmp_path
) -> None:
"""The pre-2.0 form errors out whatever the file path looks like.

A path starting with ``/`` or ``.`` used to make click print the help and exit 0,
so every script passing a full path appeared to succeed without converting anything.
"""
monkeypatch.setenv("COLUMNS", "200") # So the message is not wrapped mid-sentence.
# Run from a directory of our own, so `out` appearing there is this command's doing.
shutil.copy(os.path.join(filters_dir, "PartsSepSol", "example.tex"), tmp_path)
monkeypatch.chdir(tmp_path)
if path == "ABSOLUTE": # Only known once we're in the directory holding the file.
path = os.path.abspath("example.tex")

result = CliRunner().invoke(cli, [path, "PartsSepSol"])

assert result.exit_code != 0
assert "in2lambda convert" in result.output
assert not (tmp_path / "out").exists()


def test_old_form_fails_when_run_as_the_installed_command(
filters_dir: str, tmp_path
) -> None:
"""The same holds for ``cli()``, which is what the installed command runs.

CliRunner calls ``cli.main`` instead, so it cannot see this path: on beartype
0.18.5 the import hook left ``cli`` a plain function, and ``cli()`` ran
``convert``'s body whatever the arguments.
"""
result = subprocess.run(
[
sys.executable,
"-c",
"from in2lambda.main import cli; cli()",
os.path.join(filters_dir, "PartsSepSol", "example.tex"),
"PartsSepSol",
],
capture_output=True,
text=True,
cwd=tmp_path,
env={**os.environ, "COLUMNS": "200"},
)

assert result.returncode != 0
assert "in2lambda convert" in result.stdout + result.stderr
assert not (tmp_path / "out").exists()


def test_completing_the_old_form_offers_the_subcommand() -> None:
"""Completion resolves half-typed command lines, so the guard must not fire there.

Without that exemption, `in2lambda ./questions.tex <TAB>` printed a traceback
where the shell expected candidates.
"""
completions = ShellComplete(
cli, {}, "in2lambda", "_IN2LAMBDA_COMPLETE"
).get_completions(["./questions.tex"], "")

assert [candidate.value for candidate in completions] == ["convert"]
4 changes: 3 additions & 1 deletion tests/test_conversion_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ def test_cli_exits_with_message(filters_dir: str, monkeypatch, tmp_path) -> None
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)])
result = CliRunner().invoke(
cli, ["convert", example, "PartsSepSol", "-o", str(tmp_path)]
)

assert result.exit_code != 0
assert PANFLUTE_HINT in result.output
Expand Down
2 changes: 1 addition & 1 deletion tests/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def test_cli_reports_problems_and_exports_anyway(tmp_path) -> None:
out_dir = tmp_path / "out"

result = CliRunner().invoke(
cli, [str(question_file), "PartsOneSol", "-o", str(out_dir)]
cli, ["convert", str(question_file), "PartsOneSol", "-o", str(out_dir)]
)

assert result.exit_code == 0
Expand Down
Loading