Add native asyncclick support via patch(module=asyncclick) - #336
Conversation
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.
|
@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! |
|
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. |
|
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. |
Fixes #288
asyncclick is a fork of Click whose
command machinery (
main/invoke/make_context) is asynchronous, letting commandcallbacks be coroutines. Previously rich-click could not format an asyncclick CLI:
its formatting was bound to concrete Click-based classes, and detection used
isinstancechecks againstclick.*types that asyncclick's parallel class treedoes 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 vanillaasyncclick 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.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. Addsregister_click_impl(module)(idempotent) plusis_argument/is_command/is_group/is_option/is_parameterTypeIshelpers and type tuples. This replaces hard-codedisinstance(x, click.X)checks with a registry that any Click-compatible fork can optinto, so the renderer recognizes asyncclick's
Argument/Option/Group/etc.src/rich_click/rich_context.py-- Splits a baselessRichContextMixin(all Richstate and formatting) from the concrete
RichContext. UnderTYPE_CHECKINGthe mixinuses
click.Contextas a base so static analysis still sees inherited attributes; atruntime it is baseless (
object) so it can compose with either Click's or a fork'sContext.src/rich_click/rich_command.py-- Splits baselessRichCommandMixin/RichGroupMixinfrom the concreteRichCommand/RichGroup, which keep identical namesand MRO. Moves
_error_formatterintoRichCommandMixin(it is formatting, shared bysync and async) and makes its context check
isinstance(ctx, RichContextMixin)so it isfork-agnostic. Execution-flow methods (
main,get_help_option,to_info_dict) stay onthe concrete classes so they are not shared into the async subclasses.
src/rich_click/rich_help_rendering.pyandsrc/rich_click/rich_panel.py--Replace the in-line
isinstance(..., click.X)checks with the newis_*detectionhelpers 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. DefinesRichAsyncContext/RichAsyncCommand/RichAsyncGroup/RichAsyncCommandCollectionbycomposing the baseless mixins onto asyncclick's async bases. Provides an async
mainthat mirrors asyncclick's async
mainbut routesClickException/Abortthroughrich-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 amodule=parameter topatch()and a_patch_async_modulehelper. When a module is passed,patch()dispatches on whethermodule.Command.mainis a coroutine (is this actually an async fork) rather than on themodule name; async forks get their
Command/Group/CommandCollectionandcommand/groupdecorators swapped for theRichAsync*versions, mirroring how the existingClick path swaps
click.CommandforRichCommand. The synchronous Clickpatch()pathis unchanged.
pyproject.toml-- Adds theoptional-dependencies.async = ["asyncclick>=8.1"]extra (
rich-click[async]); a version-markedasyncclickin thedevextra so the testsuite 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 forrich_click.rich_async_commanddisabling onlymiscandattr-defined. Those two codesare 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_dictmismatch during development.examples/13_async.py(new) -- A runnable example: a vanilla asyncclick CLIrendered via
patch(module=asyncclick). It guards the asyncclick import and exits 0 whenthe optional dependency is absent, so the existing
python examples/*.py --helpCIpasses on a base install.
tests/test_asyncclick.py(new) -- Nine tests, gated byimportorskip, coveringboth entry points: the
RichAsync*classes used directly (type detection, Rich helppanels, argument/option classification, async execution with shared
ctx.obj, Rich errorpanels) and
patch(module=asyncclick)end to end (run in a subprocess, sincepatch()mutates the module globally and is irreversible).
Compatibility and risk
patch()path isuntouched.
clean.
Test plan