diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f00d776..4ccd7688 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ # Changelog +## 5.6.1 - July 30, 2026 + +### Dependencies & Python support + +- **`toon_format` moved to an optional `toon` extra** (was a hard dependency). + The only `toon_format` release in our supported range is a pre-release + (`0.9.0b1`), and uv before 0.12 refuses pre-releases reached through another + package's metadata, so it cannot install any limacharlie version that + requires one. It backtracks silently to 5.3.0 instead, so + `uv tool install limacharlie` and `uvx limacharlie` land on the last release + predating the dependency, with `uv tool upgrade` reporting "Nothing to + upgrade". Pinning the exact pre-release does not help: those uv versions + honour pre-release specifiers on direct requirements only. uv 0.12.0 changed + its default to resolve transitive pre-releases the way pip does, so uv 0.12 + and later install the newest release either way; the extra is what unblocks + everyone still on 0.11 or older. **Behavior change:** a default install no + longer supports `--output toon`. `pip install 'limacharlie[toon]'` restores + it. On uv older than 0.12, name the package directly + (`uv tool install limacharlie --with 'toon-format>=0.9.0b1'`), because asking + those versions for the extra hits the same transitive pre-release rule. The + other five output formats are unaffected. Found and fixed by + [@Nynir](https://github.com/Nynir) in #325, rebased and extended in #331 + (#324). +- **`--output toon` without the encoder now fails before the command runs.** + The check moved into the CLI's argument handling, so a search no longer runs + to completion, bills the organization, and buffers every page only to find + at render time that it cannot encode the result. `--help` and shell + completion still work without the extra. The error names both install forms + (#331). + +### Thanks + +- [@Nynir](https://github.com/Nynir) for tracking down why uv installs were + silently stuck on 5.3.0 and sending the fix (#324, #325). The failure mode + was easy to miss from our side: uv reported success, just for a release + three months of features behind. + ## 5.6.0 - July 30, 2026 ### Search diff --git a/README.md b/README.md index b62e03f8..a6b5b9fc 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,14 @@ pip install limacharlie docker run refractionpoint/limacharlie:latest --help ``` +The `toon` extra adds the TOON output format (`limacharlie --output toon`); every other format works with a default install. + +```bash +pip install 'limacharlie[toon]' +``` + +On uv older than 0.12 the extra is not enough, because `toon_format` publishes only a pre-release and those versions resolve pre-releases for directly named requirements only. Name it directly there: `uv tool install limacharlie --with 'toon-format>=0.9.0b1'`. See [the CLI output formats guide](doc/cli/README.md#output-formats) for details. + See [Getting Started](doc/getting-started.md) for Docker credential mounting and first steps. ## Quick Start diff --git a/doc/cli/README.md b/doc/cli/README.md index 0ca0e911..dcfeabb1 100644 --- a/doc/cli/README.md +++ b/doc/cli/README.md @@ -31,6 +31,20 @@ limacharlie sensor list --output table # Rich table (default for TTY) limacharlie sensor list --output jsonl # Newline-delimited JSON ``` +`--output toon` needs the optional `toon` extra; the other formats work with a default install. Asking for TOON without it fails immediately, before the command runs. + +```bash +pip install 'limacharlie[toon]' +``` + +On uv older than 0.12, name the package directly instead. `toon_format` only publishes a pre-release, and those uv versions resolve pre-releases for directly named requirements only, so asking them for the extra makes them fall back to an older `limacharlie`: + +```bash +uv tool install limacharlie --with 'toon-format>=0.9.0b1' +``` + +uv 0.12.0 changed its default to resolve transitive pre-releases the way pip does, so on 0.12 and later `uv tool install 'limacharlie[toon]'` works and the `--with` form is unnecessary. + ## Filtering with JMESPath Use `--filter` with a [JMESPath](https://jmespath.org/) expression to extract or transform output. This works with every command and any output format. diff --git a/doc/getting-started.md b/doc/getting-started.md index 4f20f321..45834c8c 100644 --- a/doc/getting-started.md +++ b/doc/getting-started.md @@ -8,6 +8,8 @@ pip install limacharlie ``` +The `--output toon` format needs the optional `toon` extra (`pip install 'limacharlie[toon]'`); everything else works with the install above. See [Output Formats](cli/README.md#output-formats) for the uv caveat. + Docker: ```bash diff --git a/limacharlie/cli.py b/limacharlie/cli.py index 033cda5c..0a0eac97 100644 --- a/limacharlie/cli.py +++ b/limacharlie/cli.py @@ -91,6 +91,23 @@ def _config_no_warnings() -> bool: return False +# Flags that make an invocation describe a command instead of running it. +# --ai-help is ours (see ai_help.py); -h/--help come from the group's +# help_option_names. +_HELP_FLAGS = frozenset({"-h", "--help", "--ai-help"}) + + +def _wants_help() -> bool: + """Whether this invocation only asks a command to describe itself. + + Click resolves the root group's parameters before a subcommand parses its + own ``--help``, so the root callback cannot tell the two apart from its + arguments alone and has to read the command line. ``main()`` reads + ``sys.argv`` the same way to decide about ``--debug``. + """ + return any(arg in _HELP_FLAGS for arg in sys.argv[1:]) + + # Static mapping: Click command name -> (module_name, attribute_name). # This allows resolving any command to its module without importing it, # enabling truly lazy per-command loading. Generated from the current @@ -387,12 +404,19 @@ def cli(ctx: click.Context, oid: str | None, output_format: str | None, debug: b # Lazy import: output pulls in jmespath, tabulate, yaml, csv (~14ms). # Deferring to here avoids that cost for fast paths like --help, --version, # and --ai-help that never render command output. - from .output import set_filter_expr, set_wide_mode, set_fields, set_sort_by, set_reverse + from .output import ensure_format_available, set_filter_expr, set_wide_mode, set_fields, set_sort_by, set_reverse set_wide_mode(wide) set_filter_expr(filter_expr) set_fields(field_list) set_sort_by(sort_by) set_reverse(reverse) + # Reject a format whose encoder ships in an extra before the subcommand + # runs, rather than after it has already spent the user's time and quota. + # Help and completion must keep working without the extra: this callback + # runs before a subcommand's own --help is parsed, so describing a command + # would otherwise fail on an unrelated --output. + if not ctx.resilient_parsing and not _wants_help(): + ensure_format_available(output_format) # Inject --ai-help on the root cli group itself (subcommands get it lazily diff --git a/limacharlie/output.py b/limacharlie/output.py index 71bf111b..9f5acc33 100644 --- a/limacharlie/output.py +++ b/limacharlie/output.py @@ -33,6 +33,16 @@ except ImportError: _toon_format = None +# Raised wherever TOON output is requested without the 'toon' extra. uv before +# 0.12 resolves toon_format's pre-release only when toon-format is named as a +# direct requirement, so asking those versions for the extra is not enough; +# uv 0.12 and later need only the pip form. +_MISSING_TOON_MESSAGE = ( + "toon_format is required for --output toon. " + "Install with: pip install 'limacharlie[toon]'\n" + "On uv older than 0.12: uv tool install limacharlie --with 'toon-format>=0.9.0b1'" +) + # Module-level flags set by the CLI before any command runs. _wide_mode: bool = False _filter_expr: str | None = None @@ -83,6 +93,22 @@ def detect_output_format() -> str: return "json" +def ensure_format_available(fmt: str | None) -> None: + """Raise ImportError if fmt needs an optional dependency that is missing. + + Formats whose encoder ships in an extra can only fail once there is + something to render, which is after the command has already done its work: + a search would run to completion, bill the organization, and buffer every + page before `--output toon` discovered it had no encoder. Calling this as + the format is selected turns that into an up-front refusal. + + Only 'toon' is optional; every other format is satisfied by a default + install, so any other value (including None) is a no-op. + """ + if fmt == "toon" and _toon_format is None: + raise ImportError(_MISSING_TOON_MESSAGE) + + def format_output( data: Any, fmt: str | None = None, @@ -181,10 +207,7 @@ def format_toon(data: Any) -> str: See https://toonformat.dev for the spec. """ if _toon_format is None: - raise ImportError( - "toon_format is required for --output toon. " - "Install with: pip install toon_format" - ) + raise ImportError(_MISSING_TOON_MESSAGE) return _toon_format.encode(data) diff --git a/pyproject.toml b/pyproject.toml index 6c0048f6..36fcd9c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,10 +37,17 @@ dependencies = [ "jmespath==1.1.0", "orjson>=3.10.0", "websockets>=13.0", - "toon_format>=0.9.0b1,<1.0", ] [project.optional-dependencies] +# TOON is one of six opt-in --output formats, and the only toon_format release +# in our supported range is a pre-release (0.9.0b1). uv before 0.12 rejects +# pre-releases reached through another package's metadata, so it cannot install +# any version of limacharlie that requires one. Keeping toon_format optional +# lets those uv versions install us: pip install 'limacharlie[toon]'. +toon = [ + "toon_format>=0.9.0b1,<1.0", +] dev = [ # pytest 9.x carries the CVE-2025-71176 fix and requires Python >= 3.10, # which matches our minimum supported version. @@ -49,6 +56,9 @@ dev = [ "pytest-benchmark>=5.0.0", # tomllib is stdlib from 3.11; Python 3.10 still needs the tomli backport. "tomli>=1.0; python_version < '3.11'", + # Keep the optional TOON output path under test. Self-referencing the extra + # rather than repeating its requirement keeps one version range to bump. + "limacharlie[toon]", ] [project.scripts] diff --git a/tests/unit/test_output.py b/tests/unit/test_output.py index f89836c6..5296f781 100644 --- a/tests/unit/test_output.py +++ b/tests/unit/test_output.py @@ -1,10 +1,13 @@ """Tests for limacharlie.output module.""" import json +import sys from unittest.mock import patch +import pytest import yaml +import limacharlie.output as output_mod from limacharlie.output import ( format_output, format_json, @@ -21,7 +24,15 @@ _table_value, ) -import toon_format +try: + import toon_format +except ImportError: # toon_format ships in the optional 'toon' extra. + toon_format = None + +requires_toon = pytest.mark.skipif( + toon_format is None, + reason="requires the optional 'toon' extra: pip install 'limacharlie[toon]'", +) class TestFormatJson: @@ -56,6 +67,7 @@ def test_list(self): assert parsed == [1, 2, 3] +@requires_toon class TestFormatToon: def test_dict_roundtrip(self): data = {"name": "Alice", "age": 30} @@ -99,18 +111,142 @@ def test_unicode(self): def test_empty_list(self): assert toon_format.decode(format_toon([])) == [] - def test_raises_import_error_when_missing(self): - """format_toon should raise a descriptive ImportError if the - toon_format package is unavailable at call time.""" - import limacharlie.output as output_mod - saved = output_mod._toon_format - output_mod._toon_format = None - try: - import pytest - with pytest.raises(ImportError, match="toon_format"): - format_toon({"a": 1}) - finally: - output_mod._toon_format = saved + def test_ensure_format_available_passes_when_installed(self): + """With the extra present the CLI's pre-flight check lets TOON through.""" + output_mod.ensure_format_available("toon") + + +class TestFormatToonMissingExtra: + """TOON output is opt-in: toon_format lives in the 'toon' extra, so a + default install has no encoder and every path into TOON has to say so.""" + + @pytest.fixture(autouse=True) + def _toon_unavailable(self, monkeypatch): + monkeypatch.setattr(output_mod, "_toon_format", None) + + def test_format_toon_error_points_at_the_extra(self): + """The error has to name the extra, not the bare package: installing + toon_format by hand next to a pipx/uv-managed CLI does not put it on + the CLI's path.""" + with pytest.raises(ImportError) as excinfo: + format_toon({"a": 1}) + assert "limacharlie[toon]" in str(excinfo.value) + + def test_format_output_toon_error_points_at_the_extra(self): + """--output toon routes through format_output, not format_toon.""" + with pytest.raises(ImportError) as excinfo: + format_output({"a": 1}, fmt="toon") + assert "limacharlie[toon]" in str(excinfo.value) + + def test_format_toon_error_offers_a_uv_form(self): + """`uv pip install 'limacharlie[toon]'` does not work on uv before 0.12: + toon_format's only in-range release is a pre-release, and those versions + honour pre-release specifiers on direct requirements only. Told to + install the extra, they silently backtrack to a limacharlie old enough + not to want it. The error has to give those users a form that names + toon-format directly.""" + with pytest.raises(ImportError) as excinfo: + format_toon({"a": 1}) + message = str(excinfo.value) + assert "uv" in message + assert "toon-format>=0.9.0b1" in message + + def test_ensure_format_available_rejects_toon(self): + """The pre-flight guard the CLI calls before dispatching a command.""" + with pytest.raises(ImportError) as excinfo: + output_mod.ensure_format_available("toon") + assert "limacharlie[toon]" in str(excinfo.value) + + @pytest.mark.parametrize("fmt", ["json", "yaml", "csv", "table", "jsonl", None]) + def test_ensure_format_available_passes_other_formats(self, fmt): + """TOON is the only format whose encoder ships in an extra.""" + output_mod.ensure_format_available(fmt) + + def test_both_toon_guards_give_the_same_message(self): + """One install hint, so the pre-flight and render-time paths cannot + drift into telling users two different things.""" + with pytest.raises(ImportError) as pre_flight: + output_mod.ensure_format_available("toon") + with pytest.raises(ImportError) as render_time: + format_toon({"a": 1}) + assert str(pre_flight.value) == str(render_time.value) + + def test_cli_reports_the_extra_without_a_traceback(self, monkeypatch, tmp_path, capsys): + """End users see a one-line hint and exit 1, not a stack trace.""" + from limacharlie.cli import main + + # main() prints a traceback when LC_DEBUG is set, so a developer with + # it exported would otherwise see this fail for the wrong reason. + monkeypatch.delenv("LC_DEBUG", raising=False) + monkeypatch.setenv("LC_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr( + sys, "argv", ["limacharlie", "--output", "toon", "config", "show-paths"] + ) + with pytest.raises(SystemExit) as excinfo: + main() + + assert excinfo.value.code == 1 + err = capsys.readouterr().err + assert "limacharlie[toon]" in err + assert "Traceback" not in err + + def test_cli_rejects_toon_before_running_the_command(self, monkeypatch, tmp_path, capsys): + """The refusal comes from the root group, not from rendering. + + A search that reaches format time has already run, been billed, and + buffered every page; finding the encoder missing there wastes all of + it. Checked on the cheapest subcommand there is: its callback never + runs. + """ + from limacharlie.cli import cli, main + + show_paths = cli.get_command(None, "config").get_command(None, "show-paths") + ran = [] + monkeypatch.setattr(show_paths, "callback", lambda *a, **kw: ran.append(True)) + + monkeypatch.delenv("LC_DEBUG", raising=False) + monkeypatch.setenv("LC_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr( + sys, "argv", ["limacharlie", "--output", "toon", "config", "show-paths"] + ) + with pytest.raises(SystemExit) as excinfo: + main() + + assert excinfo.value.code == 1 + assert ran == [], "subcommand ran despite the missing TOON encoder" + assert "limacharlie[toon]" in capsys.readouterr().err + + @pytest.mark.parametrize( + "argv", + [ + ["limacharlie", "--output", "toon", "--help"], + ["limacharlie", "--output", "toon", "config", "--help"], + ["limacharlie", "--output", "toon", "config", "show-paths", "--help"], + ], + ) + def test_help_works_without_the_encoder(self, monkeypatch, tmp_path, capsys, argv): + """Describing a command must not depend on an optional output encoder. + + The root callback runs before a subcommand parses its own --help, so a + pre-flight check that did not exempt help would break every + `--output toon --help` on a default install. + """ + from limacharlie.cli import main + + monkeypatch.delenv("LC_DEBUG", raising=False) + monkeypatch.setenv("LC_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr(sys, "argv", argv) + # click returns 0 instead of raising when standalone_mode is off, so a + # help run falls off the end of main() and the process exits 0. A + # rejected --output would raise SystemExit(1) out of this call. + main() + + assert "Usage:" in capsys.readouterr().out + + def test_other_formats_still_work(self): + """Only TOON degrades; the rest of --output is unaffected.""" + assert json.loads(format_output({"a": 1}, fmt="json")) == {"a": 1} + assert yaml.safe_load(format_output({"a": 1}, fmt="yaml")) == {"a": 1} class TestFormatCsv: @@ -220,21 +356,25 @@ def test_yaml_format(self): result = format_output({"key": "val"}, fmt="yaml") assert yaml.safe_load(result) == {"key": "val"} + @requires_toon def test_toon_format(self): result = format_output({"key": "val"}, fmt="toon") assert toon_format.decode(result) == {"key": "val"} + @requires_toon def test_toon_respects_field_selection(self): data = [{"name": "a", "value": 1, "extra": "x"}] result = format_output(data, fmt="toon", fields=["name", "value"]) decoded = toon_format.decode(result) assert decoded == [{"name": "a", "value": 1}] + @requires_toon def test_toon_respects_jmespath_filter(self): data = {"items": [1, 2, 3]} result = format_output(data, fmt="toon", filter_expr="items[0]") assert toon_format.decode(result) == 1 + @requires_toon def test_toon_respects_sort(self): data = [{"n": "b"}, {"n": "a"}, {"n": "c"}] result = format_output(data, fmt="toon", sort_by="n") diff --git a/tests/unit/test_packaging.py b/tests/unit/test_packaging.py index 6c0b45e0..b0e855a7 100644 --- a/tests/unit/test_packaging.py +++ b/tests/unit/test_packaging.py @@ -1,6 +1,7 @@ """Tests for pyproject.toml packaging and distribution.""" import pathlib +import re import sys import pytest @@ -14,6 +15,20 @@ PROJECT_ROOT = pathlib.Path(__file__).parent.parent.parent +def _dep_name(requirement: str) -> str: + """Normalized distribution name from a PEP 508 requirement string.""" + name = re.split(r"[<>=!~;\[\s]", requirement, maxsplit=1)[0] + return name.strip().lower().replace("_", "-") + + +def _dep_extras(requirement: str) -> set[str]: + """Extras requested by a PEP 508 requirement string, e.g. {'toon'}.""" + match = re.search(r"\[([^\]]*)\]", requirement) + if match is None: + return set() + return {e.strip().lower() for e in match.group(1).split(",") if e.strip()} + + class TestPyprojectToml: def test_pyproject_exists(self): assert (PROJECT_ROOT / "pyproject.toml").exists() @@ -55,6 +70,52 @@ def test_dev_dependencies(self): dep_names = [d.split("==")[0].split(">=")[0] for d in dev_deps] assert "pytest" in dep_names + def test_toon_format_is_not_a_hard_dependency(self): + """toon_format must stay out of [project.dependencies]. + + The only release satisfying our range is a pre-release (0.9.0b1), and + uv before 0.12 refuses to install any limacharlie version that requires + one -- it silently backtracks to an older release instead. TOON is one + of six opt-in --output formats, so it belongs in an extra rather than + stranding every user on those uv versions. + """ + with open(PROJECT_ROOT / "pyproject.toml", "rb") as f: + data = tomllib.load(f) + deps = data["project"].get("dependencies", []) + toon_deps = [d for d in deps if _dep_name(d) == "toon-format"] + assert not toon_deps, ( + f"toon_format must be an optional extra, not a hard dependency: {toon_deps}" + ) + + def test_toon_extra_defined(self): + """The 'toon' extra restores TOON output for anyone who wants it.""" + with open(PROJECT_ROOT / "pyproject.toml", "rb") as f: + data = tomllib.load(f) + extras = data["project"].get("optional-dependencies", {}) + assert "toon" in extras, "Missing 'toon' optional-dependencies group" + assert [d for d in extras["toon"] if _dep_name(d) == "toon-format"], ( + f"The 'toon' extra should require toon_format, got {extras['toon']}" + ) + + def test_dev_extra_includes_toon(self): + """CI installs [dev]; it must keep exercising the TOON output path. + + Either form does that: a direct toon_format requirement, or the + self-reference limacharlie[toon] that pulls the extra in. + """ + with open(PROJECT_ROOT / "pyproject.toml", "rb") as f: + data = tomllib.load(f) + dev_deps = data["project"].get("optional-dependencies", {}).get("dev", []) + pulls_toon = [ + d for d in dev_deps + if _dep_name(d) == "toon-format" + or (_dep_name(d) == "limacharlie" and "toon" in _dep_extras(d)) + ] + assert pulls_toon, ( + "The 'dev' extra should install toon_format, either directly or via " + f"limacharlie[toon], got {dev_deps}" + ) + def test_classifiers_present(self): with open(PROJECT_ROOT / "pyproject.toml", "rb") as f: data = tomllib.load(f) diff --git a/tests/unit/test_search_output.py b/tests/unit/test_search_output.py index c042e08a..7d78118e 100644 --- a/tests/unit/test_search_output.py +++ b/tests/unit/test_search_output.py @@ -9,6 +9,7 @@ from __future__ import annotations +import importlib.util import json import sys import time @@ -503,6 +504,10 @@ def test_yaml_format_passes_raw(self, capsys): captured = capsys.readouterr() assert "type: events" in captured.out + @pytest.mark.skipif( + importlib.util.find_spec("toon_format") is None, + reason="requires the optional 'toon' extra: pip install 'limacharlie[toon]'", + ) def test_toon_format_passes_raw(self, capsys): """TOON output should pass raw results unchanged.""" results = [_make_search_result("events", rows=[_make_event_row()])]