From 7eef90dc2d1402a9fb72b25fffccecf0572ab0c2 Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Sun, 20 Sep 2026 09:42:20 +0100 Subject: [PATCH 1/3] implement: Make the old CLI form fail loudly (t14) --- CHANGELOG.md | 8 +++ docs/source/contributing/new_version.md | 2 + docs/source/quickstart.md | 6 +-- in2lambda/main.py | 28 +++++++++- poetry.lock | 21 ++++---- pyproject.toml | 6 ++- tests/test_cli.py | 70 +++++++++++++++++++++++++ tests/test_conversion_tools.py | 4 +- 8 files changed, 127 insertions(+), 18 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 tests/test_cli.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f113534 --- /dev/null +++ b/CHANGELOG.md @@ -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`. Below 0.18 its import hook sent every `in2lambda ...` invocation to the first subcommand. +- The Python API is unchanged: `in2lambda.main.runner` and everything under `in2lambda.api` take the same arguments and return the same objects. diff --git a/docs/source/contributing/new_version.md b/docs/source/contributing/new_version.md index 91ff8c2..25ed67a 100644 --- a/docs/source/contributing/new_version.md +++ b/docs/source/contributing/new_version.md @@ -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 diff --git a/docs/source/quickstart.md b/docs/source/quickstart.md index 6087ba2..46b1dfb 100644 --- a/docs/source/quickstart.md +++ b/docs/source/quickstart.md @@ -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. @@ -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} @@ -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. diff --git a/in2lambda/main.py b/in2lambda/main.py index e424b58..c3f580b 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -8,6 +8,7 @@ import importlib import importlib.util +import shlex import shutil import subprocess from typing import Optional @@ -180,10 +181,33 @@ 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. + """ + if 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) ) @@ -209,7 +233,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.""" diff --git a/poetry.lock b/poetry.lock index d584734..0ce9238 100644 --- a/poetry.lock +++ b/poetry.lock @@ -29,21 +29,22 @@ dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)" [[package]] name = "beartype" -version = "0.17.2" -description = "Unbearably fast runtime type checking in pure Python." +version = "0.22.9" +description = "Unbearably fast near-real-time pure-Python runtime-static type-checker." optional = false -python-versions = ">=3.8.0" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "beartype-0.17.2-py3-none-any.whl", hash = "sha256:c22b21e1f785cfcf5c4d3d13070f532b6243a3ad67e68d2298ff08d539847dce"}, - {file = "beartype-0.17.2.tar.gz", hash = "sha256:e911e1ae7de4bccd15745f7643609d8732f64de5c2fb844e89cbbed1c5a8d495"}, + {file = "beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2"}, + {file = "beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f"}, ] [package.extras] -all = ["typing-extensions (>=3.10.0.0)"] -dev = ["autoapi (>=0.9.0)", "coverage (>=5.5)", "equinox", "mypy (>=0.800) ; platform_python_implementation != \"PyPy\"", "numpy ; sys_platform != \"darwin\" and platform_python_implementation != \"PyPy\"", "pandera", "pydata-sphinx-theme (<=0.7.2)", "pytest (>=4.0.0)", "sphinx (>=4.2.0,<6.0.0)", "sphinx ; python_version >= \"3.8.0\"", "sphinxext-opengraph (>=0.7.5)", "tox (>=3.20.1)", "typing-extensions (>=3.10.0.0)"] -doc-rtd = ["autoapi (>=0.9.0)", "pydata-sphinx-theme (<=0.7.2)", "sphinx (>=4.2.0,<6.0.0)", "sphinxext-opengraph (>=0.7.5)"] -test-tox = ["equinox", "mypy (>=0.800) ; platform_python_implementation != \"PyPy\"", "numpy ; sys_platform != \"darwin\" and platform_python_implementation != \"PyPy\"", "pandera", "pytest (>=4.0.0)", "sphinx ; python_version >= \"3.8.0\"", "typing-extensions (>=3.10.0.0)"] +dev = ["autoapi (>=0.9.0)", "celery", "click", "coverage (>=5.5)", "docutils (>=0.22.0)", "equinox ; sys_platform == \"linux\" and python_version < \"3.15.0\"", "fastmcp ; python_version < \"3.14.0\"", "jax[cpu] ; sys_platform == \"linux\" and python_version < \"3.15.0\"", "jaxtyping ; sys_platform == \"linux\"", "langchain ; python_version < \"3.14.0\" and sys_platform != \"darwin\" and platform_python_implementation != \"PyPy\"", "mypy (>=0.800) ; platform_python_implementation != \"PyPy\"", "nuitka (>=1.2.6) ; sys_platform == \"linux\" and python_version < \"3.14.0\"", "numba ; python_version < \"3.14.0\"", "numpy ; python_version < \"3.15.0\" and sys_platform != \"darwin\" and platform_python_implementation != \"PyPy\"", "pandera (>=0.26.0) ; python_version < \"3.14.0\"", "poetry", "polars ; python_version < \"3.14.0\"", "pydata-sphinx-theme (<=0.7.2)", "pygments", "pyinstaller", "pyright (>=1.1.370)", "pytest (>=6.2.0)", "redis", "rich-click", "setuptools", "sphinx", "sphinx (>=4.2.0,<6.0.0)", "sphinxext-opengraph (>=0.7.5)", "sqlalchemy", "torch ; sys_platform == \"linux\" and python_version < \"3.14.0\"", "tox (>=3.20.1)", "typer", "typing-extensions (>=3.10.0.0)", "xarray ; python_version < \"3.15.0\""] +doc-ghp = ["mkdocs-material[imaging] (>=9.6.0)", "mkdocstrings-python (>=1.16.0)", "mkdocstrings-python-xref (>=1.16.0)"] +doc-rtd = ["autoapi (>=0.9.0)", "pydata-sphinx-theme (<=0.7.2)", "setuptools", "sphinx (>=4.2.0,<6.0.0)", "sphinxext-opengraph (>=0.7.5)"] +test = ["celery", "click", "coverage (>=5.5)", "docutils (>=0.22.0)", "equinox ; sys_platform == \"linux\" and python_version < \"3.15.0\"", "fastmcp ; python_version < \"3.14.0\"", "jax[cpu] ; sys_platform == \"linux\" and python_version < \"3.15.0\"", "jaxtyping ; sys_platform == \"linux\"", "langchain ; python_version < \"3.14.0\" and sys_platform != \"darwin\" and platform_python_implementation != \"PyPy\"", "mypy (>=0.800) ; platform_python_implementation != \"PyPy\"", "nuitka (>=1.2.6) ; sys_platform == \"linux\" and python_version < \"3.14.0\"", "numba ; python_version < \"3.14.0\"", "numpy ; python_version < \"3.15.0\" and sys_platform != \"darwin\" and platform_python_implementation != \"PyPy\"", "pandera (>=0.26.0) ; python_version < \"3.14.0\"", "poetry", "polars ; python_version < \"3.14.0\"", "pygments", "pyinstaller", "pyright (>=1.1.370)", "pytest (>=6.2.0)", "redis", "rich-click", "sphinx", "sqlalchemy", "torch ; sys_platform == \"linux\" and python_version < \"3.14.0\"", "tox (>=3.20.1)", "typer", "typing-extensions (>=3.10.0.0)", "xarray ; python_version < \"3.15.0\""] +test-tox = ["celery", "click", "docutils (>=0.22.0)", "equinox ; sys_platform == \"linux\" and python_version < \"3.15.0\"", "fastmcp ; python_version < \"3.14.0\"", "jax[cpu] ; sys_platform == \"linux\" and python_version < \"3.15.0\"", "jaxtyping ; sys_platform == \"linux\"", "langchain ; python_version < \"3.14.0\" and sys_platform != \"darwin\" and platform_python_implementation != \"PyPy\"", "mypy (>=0.800) ; platform_python_implementation != \"PyPy\"", "nuitka (>=1.2.6) ; sys_platform == \"linux\" and python_version < \"3.14.0\"", "numba ; python_version < \"3.14.0\"", "numpy ; python_version < \"3.15.0\" and sys_platform != \"darwin\" and platform_python_implementation != \"PyPy\"", "pandera (>=0.26.0) ; python_version < \"3.14.0\"", "poetry", "polars ; python_version < \"3.14.0\"", "pygments", "pyinstaller", "pyright (>=1.1.370)", "pytest (>=6.2.0)", "redis", "rich-click", "sphinx", "sqlalchemy", "torch ; sys_platform == \"linux\" and python_version < \"3.14.0\"", "typer", "typing-extensions (>=3.10.0.0)", "xarray ; python_version < \"3.15.0\""] test-tox-coverage = ["coverage (>=5.5)"] [[package]] @@ -1956,4 +1957,4 @@ convert = ["panflute"] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "bb8472b338721580c3bff360fa28de7cdbb63d1c41b651b032796fec22087826" +content-hash = "c66ed1332073cc43e889ad3646544b370b7bad03c41beb4952daf4a2623f62f5" diff --git a/pyproject.toml b/pyproject.toml index e42cb7c..9adbed8 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" @@ -25,7 +25,9 @@ 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" +# Below 0.18, the import hook in in2lambda/__init__.py sends every `in2lambda ...` +# invocation to the first subcommand instead of the group. +beartype = "^0.22" [tool.poetry.extras] # Only needed to convert documents; the Python API works without it. diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..63be7ba --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,70 @@ +"""What the command line does with the current and the pre-2.0 form.""" + +import os +import subprocess +import sys + +import pytest +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. + monkeypatch.chdir(os.path.join(filters_dir, "PartsSepSol")) + 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: beartype's + import hook broke it below 0.18 by sending ``cli()`` to the first subcommand. + """ + 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() diff --git a/tests/test_conversion_tools.py b/tests/test_conversion_tools.py index 03d9ff2..26c1bc4 100644 --- a/tests/test_conversion_tools.py +++ b/tests/test_conversion_tools.py @@ -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 From 6cb4eb57aaba79c555009de9eb597bc8863697e9 Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Sun, 20 Sep 2026 09:47:58 +0100 Subject: [PATCH 2/3] implement: Make the old CLI form fail loudly (t14) --- tests/test_cli.py | 5 ++++- tests/test_runner.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 63be7ba..21f3097 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ """What the command line does with the current and the pre-2.0 form.""" import os +import shutil import subprocess import sys @@ -32,7 +33,9 @@ def test_old_form_fails_and_names_convert( 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. - monkeypatch.chdir(os.path.join(filters_dir, "PartsSepSol")) + # 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") diff --git a/tests/test_runner.py b/tests/test_runner.py index e0b24a0..5f728ac 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -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 From 19272d86fadb0d93c4f87069641020b2bf558bc6 Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Sun, 20 Sep 2026 09:57:17 +0100 Subject: [PATCH 3/3] implement: Make the old CLI form fail loudly (t14) --- CHANGELOG.md | 2 +- in2lambda/main.py | 3 ++- pyproject.toml | 6 ++++-- tests/test_cli.py | 19 +++++++++++++++++-- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f113534..cc2ce88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,5 +4,5 @@ - 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`. Below 0.18 its import hook sent every `in2lambda ...` invocation to the first subcommand. +- 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. diff --git a/in2lambda/main.py b/in2lambda/main.py index 7e77524..52b1de0 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -196,7 +196,8 @@ def resolve_command(self, ctx, args): # type: ignore[no-untyped-def] group's help and exiting successfully, so `in2lambda /path/to/questions.tex PartsSepSol` would look like it had worked while converting nothing. """ - if self.get_command(ctx, args[0]) is None: + # 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)}" ) diff --git a/pyproject.toml b/pyproject.toml index b36b8cd..3affe96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,8 +25,10 @@ documentation = "https://lambda-feedback.github.io/in2lambda" python = "^3.10" panflute = { version = "^2.3.1", optional = true } rich-click = "^1.7.4" -# Below 0.18, the import hook in in2lambda/__init__.py sends every `in2lambda ...` -# invocation to the first subcommand instead of the group. +# 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] diff --git a/tests/test_cli.py b/tests/test_cli.py index 21f3097..a8b96ea 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,6 +6,7 @@ import sys import pytest +from click.shell_completion import ShellComplete from click.testing import CliRunner from in2lambda.main import cli @@ -51,8 +52,9 @@ def test_old_form_fails_when_run_as_the_installed_command( ) -> None: """The same holds for ``cli()``, which is what the installed command runs. - CliRunner calls ``cli.main`` instead, so it cannot see this path: beartype's - import hook broke it below 0.18 by sending ``cli()`` to the first subcommand. + 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( [ @@ -71,3 +73,16 @@ def test_old_form_fails_when_run_as_the_installed_command( 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 ` 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"]