From a1534e4bf0b35089e2d76f784c072d624b87c06f Mon Sep 17 00:00:00 2001 From: Hayden Covington Date: Sat, 25 Jul 2026 16:44:12 -0400 Subject: [PATCH 1/2] fix(deps): make toon_format an optional extra so uv can install current releases Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 20 ++++++++ README.md | 4 ++ doc/cli/README.md | 12 +++++ limacharlie/output.py | 5 +- pyproject.toml | 10 +++- tests/unit/test_output.py | 85 +++++++++++++++++++++++++++----- tests/unit/test_packaging.py | 43 ++++++++++++++++ tests/unit/test_search_output.py | 5 ++ 8 files changed, 169 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f00d776..9b4442aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## Unreleased + +### 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 resolvers that refuse transitive pre-releases - uv among + them - cannot install *any* limacharlie version that requires it. uv + backtracks silently to 5.3.0 instead, so `uv tool install limacharlie` and + `uvx limacharlie` have been stuck on the last release predating the + dependency, with `uv tool upgrade` reporting "Nothing to upgrade". Pinning + the exact pre-release would not help: uv honours pre-release specifiers only + on direct requirements, never on transitive ones. **Behavior change:** a + default install no longer supports `--output toon`. + `pip install 'limacharlie[toon]'` restores it; under uv, name the package + directly (`uv tool install limacharlie --with 'toon-format>=0.9.0b1'`), + because asking uv for the extra hits the same transitive pre-release rule. + The other five output formats are unaffected, and requesting TOON without + the encoder now points at both install forms. + ## 5.6.0 - July 30, 2026 ### Search diff --git a/README.md b/README.md index b62e03f8..9ffbea96 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ Python SDK and command-line interface for the [LimaCharlie](https://limacharlie. ```bash pip install limacharlie + +# Optional: adds the TOON output format (limacharlie --output toon) +pip install 'limacharlie[toon]' +uv tool install limacharlie --with 'toon-format>=0.9.0b1' # uv equivalent ``` ```bash diff --git a/doc/cli/README.md b/doc/cli/README.md index 0ca0e911..5692f7ee 100644 --- a/doc/cli/README.md +++ b/doc/cli/README.md @@ -31,6 +31,18 @@ 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. + +```bash +pip install 'limacharlie[toon]' +``` + +Under uv, name the package directly instead — `toon_format` only publishes a pre-release, and uv resolves pre-releases for directly named requirements only, so asking it for the extra makes it fall back to an older `limacharlie`: + +```bash +uv tool install limacharlie --with 'toon-format>=0.9.0b1' +``` + ## 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/limacharlie/output.py b/limacharlie/output.py index 71bf111b..58e7cebb 100644 --- a/limacharlie/output.py +++ b/limacharlie/output.py @@ -181,9 +181,12 @@ def format_toon(data: Any) -> str: See https://toonformat.dev for the spec. """ if _toon_format is None: + # uv resolves the extra's pre-release only when toon-format is named as + # a direct requirement, hence the separate form. raise ImportError( "toon_format is required for --output toon. " - "Install with: pip install toon_format" + "Install with: pip install 'limacharlie[toon]'\n" + "With uv: uv tool install limacharlie --with 'toon-format>=0.9.0b1'" ) return _toon_format.encode(data) diff --git a/pyproject.toml b/pyproject.toml index 6c0048f6..3c02e2aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,10 +37,16 @@ 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). Resolvers that reject +# transitive pre-releases (uv) cannot install any version of limacharlie that +# requires it, so it stays optional: 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 +55,8 @@ 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 even though it is an extra. + "toon_format>=0.9.0b1,<1.0", ] [project.scripts] diff --git a/tests/unit/test_output.py b/tests/unit/test_output.py index f89836c6..e8eca3a2 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,61 @@ 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 + +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: toon_format's + only in-range release is a pre-release, and uv honours pre-release + specifiers on direct requirements only. Told to install the extra, uv + silently backtracks to a limacharlie old enough not to want it. The + error has to give uv 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_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 + + 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_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 +275,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..1bfefe96 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,12 @@ 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("_", "-") + + class TestPyprojectToml: def test_pyproject_exists(self): assert (PROJECT_ROOT / "pyproject.toml").exists() @@ -55,6 +62,42 @@ 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 + resolvers that reject transitive pre-releases (uv) refuse to install + any limacharlie version that requires it -- they silently backtrack to + an older release instead. TOON is one of six opt-in --output formats, + so it belongs in an extra rather than stranding every uv user. + """ + 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.""" + with open(PROJECT_ROOT / "pyproject.toml", "rb") as f: + data = tomllib.load(f) + dev_deps = data["project"].get("optional-dependencies", {}).get("dev", []) + assert [d for d in dev_deps if _dep_name(d) == "toon-format"], ( + f"The 'dev' extra should install toon_format, 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()])] From 0a97613f1d1209388c0759d62d00dfaa7a8770b2 Mon Sep 17 00:00:00 2001 From: Tomaz Muraus Date: Thu, 30 Jul 2026 12:58:36 +0200 Subject: [PATCH 2/2] fix(output): reject --output toon up front, and scope the uv caveat to <0.12 Follow-up to making toon_format an optional extra. Filed under 5.6.1, which is the tag these entries ship in. Refusing TOON only at render time means the command has already done its work: a search runs to completion, bills the organization, and buffers every page before discovering it has no encoder. That was unreachable while toon_format was a hard dependency and became a normal path once it moved to an extra. ensure_format_available() now guards the format as the CLI selects it, sharing one message with the render-time guard in format_toon() so the two cannot drift. The root group callback runs before a subcommand parses its own --help, so the check exempts help and completion; without that exemption `--output toon --help` would fail on a default install. uv 0.12.0 changed its default pre-release mode to resolve transitive pre-releases the way pip does, which fixes the resolution failure that motivated the extra. The extra still matters for uv 0.11 and older, but every present-tense claim that uv "cannot install" us is now wrong for current uv, so the CHANGELOG, README, CLI docs, pyproject comment, and the error message itself are scoped to uv older than 0.12. Also: - The dev extra self-references limacharlie[toon] instead of repeating the version range, leaving one place to bump when toon_format ships a stable release. Verified that both `pip install .[dev]` and `pip install -e .[dev]` still pull the encoder. - test_cli_reports_the_extra_without_a_traceback asserted no traceback while main() prints one when LC_DEBUG is set, so it failed for anyone with that exported. It now clears the variable. - getting-started.md said `pip install limacharlie` with no mention that TOON became opt-in, and the README ran the pip and uv forms together in one block as though both were needed. - The CHANGELOG credits @Nynir for finding and fixing the uv fallback, and references the originating issue and PRs. Verified against a wheel built from this branch: uv 0.11.33 installs it with no flags and pulls no toon-format, uv 0.12.0 and pip both resolve it, and the wheel plus [toon] restores TOON output. Full suite passes both with the extra (3859 passed, 5 skipped) and without it (3845 passed, 19 skipped). Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 45 ++++++++++++------ README.md | 12 +++-- doc/cli/README.md | 6 ++- doc/getting-started.md | 2 + limacharlie/cli.py | 26 ++++++++++- limacharlie/output.py | 34 +++++++++++--- pyproject.toml | 12 +++-- tests/unit/test_output.py | 91 ++++++++++++++++++++++++++++++++++-- tests/unit/test_packaging.py | 32 ++++++++++--- 9 files changed, 215 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b4442aa..4ccd7688 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,24 +1,41 @@ # Changelog -## Unreleased +## 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 resolvers that refuse transitive pre-releases - uv among - them - cannot install *any* limacharlie version that requires it. uv - backtracks silently to 5.3.0 instead, so `uv tool install limacharlie` and - `uvx limacharlie` have been stuck on the last release predating the - dependency, with `uv tool upgrade` reporting "Nothing to upgrade". Pinning - the exact pre-release would not help: uv honours pre-release specifiers only - on direct requirements, never on transitive ones. **Behavior change:** a - default install no longer supports `--output toon`. - `pip install 'limacharlie[toon]'` restores it; under uv, name the package - directly (`uv tool install limacharlie --with 'toon-format>=0.9.0b1'`), - because asking uv for the extra hits the same transitive pre-release rule. - The other five output formats are unaffected, and requesting TOON without - the encoder now points at both install forms. + (`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 diff --git a/README.md b/README.md index 9ffbea96..a6b5b9fc 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,20 @@ Python SDK and command-line interface for the [LimaCharlie](https://limacharlie. ```bash pip install limacharlie - -# Optional: adds the TOON output format (limacharlie --output toon) -pip install 'limacharlie[toon]' -uv tool install limacharlie --with 'toon-format>=0.9.0b1' # uv equivalent ``` ```bash 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 5692f7ee..dcfeabb1 100644 --- a/doc/cli/README.md +++ b/doc/cli/README.md @@ -31,18 +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. +`--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]' ``` -Under uv, name the package directly instead — `toon_format` only publishes a pre-release, and uv resolves pre-releases for directly named requirements only, so asking it for the extra makes it fall back to an older `limacharlie`: +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 58e7cebb..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,13 +207,7 @@ def format_toon(data: Any) -> str: See https://toonformat.dev for the spec. """ if _toon_format is None: - # uv resolves the extra's pre-release only when toon-format is named as - # a direct requirement, hence the separate form. - raise ImportError( - "toon_format is required for --output toon. " - "Install with: pip install 'limacharlie[toon]'\n" - "With uv: uv tool install limacharlie --with 'toon-format>=0.9.0b1'" - ) + raise ImportError(_MISSING_TOON_MESSAGE) return _toon_format.encode(data) diff --git a/pyproject.toml b/pyproject.toml index 3c02e2aa..36fcd9c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,9 +41,10 @@ dependencies = [ [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). Resolvers that reject -# transitive pre-releases (uv) cannot install any version of limacharlie that -# requires it, so it stays optional: pip install 'limacharlie[toon]'. +# 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", ] @@ -55,8 +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 even though it is an extra. - "toon_format>=0.9.0b1,<1.0", + # 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 e8eca3a2..5296f781 100644 --- a/tests/unit/test_output.py +++ b/tests/unit/test_output.py @@ -111,6 +111,10 @@ def test_unicode(self): def test_empty_list(self): assert toon_format.decode(format_toon([])) == [] + 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 @@ -135,21 +139,45 @@ def test_format_output_toon_error_points_at_the_extra(self): 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: toon_format's - only in-range release is a pre-release, and uv honours pre-release - specifiers on direct requirements only. Told to install the extra, uv - silently backtracks to a limacharlie old enough not to want it. The - error has to give uv users a form that names toon-format directly.""" + """`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"] @@ -162,6 +190,59 @@ def test_cli_reports_the_extra_without_a_traceback(self, monkeypatch, tmp_path, 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} diff --git a/tests/unit/test_packaging.py b/tests/unit/test_packaging.py index 1bfefe96..b0e855a7 100644 --- a/tests/unit/test_packaging.py +++ b/tests/unit/test_packaging.py @@ -21,6 +21,14 @@ def _dep_name(requirement: str) -> str: 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() @@ -66,10 +74,10 @@ 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 - resolvers that reject transitive pre-releases (uv) refuse to install - any limacharlie version that requires it -- they silently backtrack to - an older release instead. TOON is one of six opt-in --output formats, - so it belongs in an extra rather than stranding every uv user. + 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) @@ -90,12 +98,22 @@ def test_toon_extra_defined(self): ) def test_dev_extra_includes_toon(self): - """CI installs [dev]; it must keep exercising the TOON output path.""" + """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", []) - assert [d for d in dev_deps if _dep_name(d) == "toon-format"], ( - f"The 'dev' extra should install toon_format, got {dev_deps}" + 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):