Skip to content

Add native asyncclick support via patch(module=asyncclick) - #336

Open
gabloe wants to merge 4 commits into
ewels:mainfrom
gabloe:main
Open

Add native asyncclick support via patch(module=asyncclick)#336
gabloe wants to merge 4 commits into
ewels:mainfrom
gabloe:main

Conversation

@gabloe

@gabloe gabloe commented Jun 21, 2026

Copy link
Copy Markdown

Fixes #288

asyncclick is a fork of Click whose
command machinery (main/invoke/make_context) is asynchronous, letting command
callbacks be coroutines. Previously rich-click could not format an asyncclick CLI:
its formatting was bound to concrete Click-based classes, and detection used
isinstance checks against click.* types that asyncclick's parallel class tree
does not subclass.

This PR makes rich-click format asyncclick CLIs the same way it formats Click CLIs.
End users call patch(module=asyncclick) once, then write a completely vanilla
asyncclick CLI and get Rich help panels and Rich error panels with no rich-click
specific code. asyncclick stays an optional dependency
(pip install rich-click[async]); rich-click's core never imports it.

import asyncclick
from rich_click.patch import patch

patch(module=asyncclick)

@asyncclick.group()
async def cli(): ...

@cli.command()
@asyncclick.argument("name")
async def greet(name):
    asyncclick.echo(f"Hello {name}")

The change is structured so the synchronous Click path is behaviorally unchanged:
concrete classes keep identical names and MRO, and no existing test output changes.

Changes by file

src/rich_click/_click_types_cache.py -- Fork-agnostic detection. Adds
register_click_impl(module) (idempotent) plus is_argument/is_command/is_group/
is_option/is_parameter TypeIs helpers and type tuples. This replaces hard-coded
isinstance(x, click.X) checks with a registry that any Click-compatible fork can opt
into, so the renderer recognizes asyncclick's Argument/Option/Group/etc.

src/rich_click/rich_context.py -- Splits a baseless RichContextMixin (all Rich
state and formatting) from the concrete RichContext. Under TYPE_CHECKING the mixin
uses click.Context as a base so static analysis still sees inherited attributes; at
runtime it is baseless (object) so it can compose with either Click's or a fork's
Context.

src/rich_click/rich_command.py -- Splits baseless RichCommandMixin/
RichGroupMixin from the concrete RichCommand/RichGroup, which keep identical names
and MRO. Moves _error_formatter into RichCommandMixin (it is formatting, shared by
sync and async) and makes its context check isinstance(ctx, RichContextMixin) so it is
fork-agnostic. Execution-flow methods (main, get_help_option, to_info_dict) stay on
the concrete classes so they are not shared into the async subclasses.

src/rich_click/rich_help_rendering.py and src/rich_click/rich_panel.py --
Replace the in-line isinstance(..., click.X) checks with the new is_* detection
helpers so help and panel rendering work on asyncclick's types as well as Click's. No
rendering behavior changes for Click.

src/rich_click/rich_async_command.py (new) -- The asyncclick bridge. Defines
RichAsyncContext/RichAsyncCommand/RichAsyncGroup/RichAsyncCommandCollection by
composing the baseless mixins onto asyncclick's async bases. Provides an async main
that mirrors asyncclick's async main but routes ClickException/Abort through
rich-click's formatter (so errors render as Rich panels), and an async to_info_dict
(asyncclick's is a coroutine). Imports asyncclick at module level (this module is only
imported when the optional dependency is present) and calls
register_click_impl(asyncclick) on import so the classes also work when used directly,
not only via patch().

src/rich_click/patch.py -- Adds a module= parameter to patch() and a
_patch_async_module helper. When a module is passed, patch() dispatches on whether
module.Command.main is a coroutine (is this actually an async fork) rather than on the
module name; async forks get their Command/Group/CommandCollection and command/
group decorators swapped for the RichAsync* versions, mirroring how the existing
Click path swaps click.Command for RichCommand. The synchronous Click patch() path
is unchanged.

pyproject.toml -- Adds the optional-dependencies.async = ["asyncclick>=8.1"]
extra (rich-click[async]); a version-marked asyncclick in the dev extra so the test
suite exercises it on Python >= 3.10 and skips cleanly on older versions; a mypy override
for asyncclick.* (ignore_missing_imports); and a narrowly-scoped mypy override for
rich_click.rich_async_command disabling only misc and attr-defined. Those two codes
are unavoidable structural artifacts of composing rich-click's Click-typed mixins with
asyncclick's separately-typed parallel class tree (diamond MRO); the bug-catching codes
(override, index, return-value, arg-type) stay enabled and caught a real async/
sync to_info_dict mismatch during development.

examples/13_async.py (new) -- A runnable example: a vanilla asyncclick CLI
rendered via patch(module=asyncclick). It guards the asyncclick import and exits 0 when
the optional dependency is absent, so the existing python examples/*.py --help CI
passes on a base install.

tests/test_asyncclick.py (new) -- Nine tests, gated by importorskip, covering
both entry points: the RichAsync* classes used directly (type detection, Rich help
panels, argument/option classification, async execution with shared ctx.obj, Rich error
panels) and patch(module=asyncclick) end to end (run in a subprocess, since patch()
mutates the module globally and is irreversible).

Compatibility and risk

  • asyncclick is optional; nothing in rich-click's import path requires it.
  • Concrete Click classes keep their names and MRO; the synchronous patch() path is
    untouched.
  • Full suite: 152 passed, 6 skipped. mypy at the existing baseline (0 new errors). ruff
    clean.
  • On interpreters without asyncclick, the example exits 0 and the asyncclick tests skip.

Test plan

pip install -e ".[dev]"
pytest tests/
python examples/13_async.py --help
python examples/13_async.py greet World
python examples/13_async.py greet     # Rich error panel, exit 2

gabloe added 4 commits June 21, 2026 16:14
asyncclick is a fork of Click whose command machinery (main/invoke/
make_context) is asynchronous. Previously rich-click could not format an
asyncclick CLI: formatting was bound to concrete Click-based classes and
detection used isinstance checks against click.* types that asyncclick's
parallel class tree does not subclass.

End users now call patch(module=asyncclick) once and write a vanilla
asyncclick CLI to get Rich help and Rich error panels with no rich-click
specific code. asyncclick stays optional (pip install rich-click[async]);
rich-click's core never imports it. The synchronous Click path is unchanged:
concrete classes keep identical names and MRO.

- _click_types_cache.py: add register_click_impl(module) plus is_* TypeIs
  helpers and type tuples for fork-agnostic detection.
- rich_context.py / rich_command.py: split baseless Rich*Mixin classes from
  the concrete classes; move _error_formatter onto RichCommandMixin and make
  its context check fork-agnostic.
- rich_help_rendering.py / rich_panel.py: use the is_* helpers instead of
  in-line isinstance(click.X) checks.
- rich_async_command.py: new asyncclick bridge classes with an async main
  that routes errors through rich-click's formatter, and an async
  to_info_dict.
- patch.py: add a module= parameter dispatching on whether module.Command.main
  is a coroutine, swapping the async classes and decorators.
- pyproject.toml: add the async extra, a version-marked dev dependency, and
  mypy overrides for the bridge module and asyncclick.
- examples/13_async.py: runnable example, guarded so the examples CI passes
  without asyncclick.
- tests/test_asyncclick.py: cover the classes directly and patch(module=)
  end to end.
The CI test job failed on Click 8.0.x and 8.1.x: rendering an asyncclick CLI
raised "Parameter.make_metavar() missing 1 required positional argument: 'ctx'".

make_metavar gained a required ctx argument in click 8.2. rich-click chose how
to call it from the globally installed click version via CLICK_IS_BEFORE_VERSION_82.
That is wrong for a fork: an asyncclick Parameter keeps asyncclick's newer
make_metavar signature (ctx required) even when click < 8.2 is installed, so the
no-ctx call path fails.

Inspect the actual make_metavar signature of the parameter object instead of
branching on the installed click version, falling back to the version flag only
if the signature cannot be read. Verified against click 8.0, 8.1 and 8.4.
Resolve the type-check errors reported by CI (mypy<1.16, run by pre-commit
on explicit files so implementations are checked rather than stubs).

- rich_command.py: annotate context_class on RichCommandMixin so the shared
  _error_formatter can reach the rich context attributes (formatter_class,
  export_console_as, errors_in_output_format); update the mixin docstring to
  note that error formatting is shared in the mixin.
- pyproject.toml: extend the rich_async_command override to also silence the
  structural override, arg-type and func-returns-value noise from bridging
  click and asyncclick parallel class trees; add a tests.test_asyncclick
  override turning off disallow_untyped_decorators, since asyncclick's
  decorators are untyped.
- tests/test_asyncclick.py: add full type annotations to every helper and test.
Apply pyproject-fmt: align dependency comments, normalize the environment
marker whitespace, and add blank lines before the new mypy override tables.
@gabloe

gabloe commented Jun 21, 2026

Copy link
Copy Markdown
Author

@dwreeves Please let me know if this change is something you would accept. I tried to make it as minimally invasive as I could. I am happy to make changes if there are any concerns. Thanks!

@dwreeves

dwreeves commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Hi @gabloe, thanks for the PR. I'll take a look when I have a moment (probably won't be for at least 2 weeks, earliest possible for me would be July 5th). I also appreciate that you attempted to make it minimally invasive; that's definitely in the spirit of the acceptance criteria for something like this.

I don't oppose asyncclick support on principle, but it's not something I want to spend a lot of time maintaining. Honestly I still frankly don't fully understand the use case of asyncclick and I'm personally a fan of decoupling interfaces from core code and owning my own event loops. Although, I know some people like asyncclick, which is good enough of a reason to support it so long as it doesn't create a maintenance burden. So what I'll really be looking for in this PR (again, when I get to have a closer look) is whether the abstractions here are sufficiently "hands-off." The less asyncclick "plumbing" that we need to port for whatever reason, the better. I do wonder how doable this is in practice, although I cannot say I'm familiar with how asyncclick works under the hood.


We don't have any policy around AI generated contributions and we do not oppose AI generated contributions (won't speak on behalf of Phil but I believe that's his position), but just for my own information and for my own accounting, can I get a rough estimate of how much of this is AI generated?

On that note I'll ask Phil if we should have an AI disclosure in our contributing guidelines (currently our contributing guidelines are from the time before AI coding was good and omnipresent); I think that would be reasonable.

@gabloe

gabloe commented Jun 22, 2026

Copy link
Copy Markdown
Author

Hi @dwreeves Thanks! I totally understand timing issues, July works.

On the maintenance concern, this was the central design goal. I did not want to introduce any asyncclick specifics that you or the other maintainers would need to take forward. No asyncclick execution logic is reimplemented. The only async-specific code is a thin bridge ( rich_async_command.py ) whose async methods just await super() and hand off to rich-click's existing logic. There should not be any ported plumbing to keep in sync with asyncclick internals. To support that, I had to implement helper functions to enable registering alternate click implementations ( register_click_impl() ) since rich-click originally used isinstance guards that referenced click-specific classes, which asyncclick does not use. Those helpers are fork-agnostic by design: they let any Click-compatible implementation register itself, so there is nothing asyncclick-specific baked into core rich-click.

On the AI question, happy to be transparent: I wrote the initial changes to support asyncclick based on a POC CLI that I built which uses rich-click + asyncclick (monkeypatch approach). I verified those changes with AI and then used AI to make sure that I covered all of the edge cases with documentation/comments. I would say this is roughly 50% implemented by AI.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Consider supporting asyncclick

2 participants