Skip to content

Commit 478cd78

Browse files
committed
fix: add click compatibility layer for CLI alias support
The `aliases` parameter in click groups is a rich-click 1.9+ feature that causes failures when users have only plain click installed or older rich-click versions. This commit introduces a compatibility layer that provides alias support across all environments. Changes: - Add `advanced_alchemy/utils/cli_tools.py` with: - `AliasedGroup` class that mimics rich-click's alias handling - `group()` and `command()` wrappers that auto-select the right class - Detection constants for rich-click availability and alias support - Update all CLI modules to use the compatibility layer: - `advanced_alchemy/cli.py` - `advanced_alchemy/extensions/fastapi/cli.py` - `advanced_alchemy/extensions/flask/cli.py` - `advanced_alchemy/extensions/litestar/cli.py` - Add comprehensive unit tests for the compatibility layer The CLI now works correctly with: - Plain click (no rich-click) - Old rich-click (< 1.9.0) - New rich-click (>= 1.9.0)
1 parent 05a4b80 commit 478cd78

File tree

7 files changed

+305
-39
lines changed

7 files changed

+305
-39
lines changed

advanced_alchemy/cli.py

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,12 @@ def get_alchemy_group() -> "Group":
2323
The Advanced Alchemy CLI group.
2424
"""
2525
from advanced_alchemy.exceptions import MissingDependencyError
26+
from advanced_alchemy.utils.cli_tools import click, group
2627

27-
try:
28-
import rich_click as click
29-
except ImportError:
30-
try:
31-
import click # type: ignore[no-redef]
32-
except ImportError as e:
33-
raise MissingDependencyError(package="click", install_package="cli") from e
28+
if click is None: # pragma: no cover - defensive guard
29+
raise MissingDependencyError(package="click", install_package="cli")
3430

35-
@click.group(name="alchemy")
31+
@group(name="alchemy") # pyright: ignore
3632
@click.option(
3733
"--config",
3834
help="Dotted path to SQLAlchemy config(s) (e.g. 'myapp.config.alchemy_configs')",
@@ -85,17 +81,10 @@ def add_migration_commands(database_group: Optional["Group"] = None) -> "Group":
8581
Returns:
8682
The database group with the migration commands added.
8783
"""
88-
from advanced_alchemy.exceptions import MissingDependencyError
89-
90-
try:
91-
import rich_click as click
92-
except ImportError:
93-
try:
94-
import click # type: ignore[no-redef]
95-
except ImportError as e:
96-
raise MissingDependencyError(package="click", install_package="cli") from e
9784
from rich import get_console
9885

86+
from advanced_alchemy.utils.cli_tools import click
87+
9988
console = get_console()
10089

10190
if database_group is None:

advanced_alchemy/extensions/fastapi/cli.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
from typing import TYPE_CHECKING, Optional, cast
22

3-
try:
4-
import rich_click as click
5-
except ImportError:
6-
import click # type: ignore[no-redef]
7-
83
from advanced_alchemy.cli import add_migration_commands
4+
from advanced_alchemy.utils.cli_tools import click, group
95

106
if TYPE_CHECKING:
117
from fastapi import FastAPI
@@ -35,7 +31,7 @@ def get_database_migration_plugin(app: "FastAPI") -> "AdvancedAlchemy": # pragm
3531

3632

3733
def register_database_commands(app: "FastAPI") -> click.Group: # pragma: no cover
38-
@click.group(name="database", aliases=["db"])
34+
@group(name="database", aliases=["db"])
3935
@click.pass_context
4036
def database_group(ctx: click.Context) -> None:
4137
"""Manage SQLAlchemy database components."""

advanced_alchemy/extensions/flask/cli.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,7 @@
99
from flask.cli import with_appcontext
1010

1111
from advanced_alchemy.cli import add_migration_commands
12-
13-
try:
14-
import rich_click as click
15-
except ImportError:
16-
import click # type: ignore[no-redef]
17-
12+
from advanced_alchemy.utils.cli_tools import click, group
1813

1914
if TYPE_CHECKING:
2015
from flask import Flask
@@ -42,17 +37,16 @@ def get_database_migration_plugin(app: "Flask") -> "AdvancedAlchemy":
4237
raise ImproperConfigurationError(msg)
4338

4439

45-
@click.group(name="database", aliases=["db"])
40+
@group(name="database", aliases=["db"]) # pyright: ignore
4641
@with_appcontext
4742
def database_group() -> None:
4843
"""Manage SQLAlchemy database components.
4944
5045
This command group provides database management commands like migrations.
5146
"""
52-
5347
ctx = cast("click.Context", click.get_current_context())
5448
app = ctx.obj.load_app()
5549
ctx.obj = {"app": app, "configs": get_database_migration_plugin(app).config}
5650

5751

58-
add_migration_commands(database_group)
52+
add_migration_commands(database_group) # pyright: ignore

advanced_alchemy/extensions/flask/extension.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from advanced_alchemy.utils.portals import Portal, PortalProvider
1717

1818
if TYPE_CHECKING:
19+
import click
1920
from flask import Flask
2021

2122

@@ -112,7 +113,8 @@ def shutdown_portal(exception: "Optional[BaseException]" = None) -> None: # pyr
112113
app.teardown_appcontext(self._teardown_appcontext)
113114

114115
app.extensions["advanced_alchemy"] = self
115-
app.cli.add_command(database_group)
116+
db_group_cmd = cast("click.Command", database_group)
117+
app.cli.add_command(db_group_cmd)
116118

117119
def _teardown_appcontext(self, exception: "Optional[BaseException]" = None) -> None:
118120
"""Clean up resources when the application context ends."""

advanced_alchemy/extensions/litestar/cli.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,7 @@
44
from litestar.cli._utils import LitestarGroup # pyright: ignore
55

66
from advanced_alchemy.cli import add_migration_commands
7-
8-
try:
9-
import rich_click as click
10-
except ImportError:
11-
import click # type: ignore[no-redef]
7+
from advanced_alchemy.utils.cli_tools import click, group
128

139
if TYPE_CHECKING:
1410
from litestar import Litestar
@@ -37,7 +33,7 @@ def get_database_migration_plugin(app: "Litestar") -> "SQLAlchemyInitPlugin":
3733
raise ImproperConfigurationError(msg)
3834

3935

40-
@click.group(cls=LitestarGroup, name="database", aliases=["db"])
36+
@group(cls=LitestarGroup, name="database", aliases=["db"]) # pyright: ignore
4137
def database_group(ctx: "click.Context") -> None:
4238
"""Manage SQLAlchemy database components."""
4339
ctx.obj = {"app": ctx.obj, "configs": get_database_migration_plugin(ctx.obj.app).config}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Compatibility utilities for Click and Rich-Click.
2+
3+
This module provides a small compatibility layer so CLI code can opt into
4+
alias support without depending on Rich-Click 1.9+ being installed. When
5+
Rich-Click with alias support is available it is used; otherwise a local
6+
``AliasedGroup`` implementation mimics the behaviour for plain Click (or
7+
older Rich-Click versions).
8+
9+
Usage:
10+
from advanced_alchemy.utils.cli_tools import click, group, command
11+
12+
@group(name="database", aliases=["db"])
13+
def database_group():
14+
...
15+
"""
16+
17+
import inspect
18+
from collections.abc import Iterable
19+
from typing import Any, Callable, Final, Optional
20+
21+
from typing_extensions import ParamSpec
22+
23+
_rich_click_available = False
24+
_rich_click_aliases_supported = False
25+
_rich_group_cls: "Optional[type[click.Group]]" = None
26+
27+
try:
28+
import rich_click as click
29+
from rich_click import RichGroup
30+
31+
_rich_group_init_params = inspect.signature(RichGroup.__init__).parameters
32+
_rich_click_available = True
33+
_rich_click_aliases_supported = "aliases" in _rich_group_init_params
34+
_rich_group_cls = RichGroup
35+
except ImportError: # Fall back to plain click
36+
import click # type: ignore[no-redef]
37+
38+
_RICH_CLICK_AVAILABLE: Final[bool] = _rich_click_available
39+
_RICH_CLICK_ALIASES_SUPPORTED: Final[bool] = _rich_click_aliases_supported
40+
41+
__all__ = [
42+
"AliasedGroup",
43+
"click",
44+
"command",
45+
"group",
46+
]
47+
48+
P = ParamSpec("P")
49+
50+
51+
def _supports_aliases_param(cls: type[Any]) -> bool:
52+
"""Return True when the provided class ``__init__`` accepts ``aliases``."""
53+
54+
try:
55+
return "aliases" in inspect.signature(cls.__init__).parameters
56+
except (TypeError, ValueError):
57+
return False
58+
59+
60+
class AliasedGroup(click.Group):
61+
"""Click group that understands command aliases.
62+
63+
The implementation mirrors Rich-Click's alias handling so that plain
64+
Click environments can keep working when ``aliases`` are supplied.
65+
"""
66+
67+
def __init__(
68+
self,
69+
*args: Any,
70+
aliases: Optional[Iterable[str]] = None,
71+
**kwargs: Any,
72+
) -> None:
73+
aliases_iterable = aliases or ()
74+
super().__init__(*args, **kwargs)
75+
self.aliases = tuple(aliases_iterable)
76+
self._alias_mapping: dict[str, str] = {}
77+
78+
def add_command(
79+
self,
80+
cmd: click.Command,
81+
name: Optional[str] = None,
82+
aliases: Optional[Iterable[str]] = None,
83+
) -> None:
84+
super().add_command(cmd, name)
85+
command_name = name or cmd.name
86+
if command_name is None:
87+
return
88+
89+
all_aliases: tuple[str, ...] = tuple(aliases or ()) + tuple(getattr(cmd, "aliases", ()) or ())
90+
for alias in all_aliases:
91+
self._alias_mapping[alias] = command_name
92+
93+
# Ensure the primary command name is not stored as an alias
94+
self._alias_mapping.pop(command_name, None)
95+
96+
def get_command(self, ctx: click.Context, cmd_name: str) -> Optional[click.Command]:
97+
resolved_name = self._alias_mapping.get(cmd_name, cmd_name)
98+
return super().get_command(ctx, resolved_name)
99+
100+
def resolve_command(
101+
self, ctx: click.Context, args: list[str]
102+
) -> tuple[Optional[str], Optional[click.Command], list[str]]:
103+
cmd_name, cmd, remaining_args = super().resolve_command(ctx, args)
104+
if cmd is None:
105+
return cmd_name, cmd, remaining_args
106+
canonical_name = cmd.name or cmd_name
107+
return canonical_name, cmd, remaining_args
108+
109+
110+
def _alias_enabled_group_class(cls: Optional[type[click.Group]], aliases: Optional[Iterable[str]]) -> type[click.Group]:
111+
"""Choose a group class that can accept ``aliases`` safely."""
112+
113+
base_cls: type[click.Group]
114+
if cls is not None:
115+
base_cls = cls
116+
elif _RICH_CLICK_ALIASES_SUPPORTED and _rich_group_cls is not None:
117+
base_cls = _rich_group_cls
118+
else:
119+
base_cls = AliasedGroup
120+
121+
if aliases is None or _supports_aliases_param(base_cls):
122+
return base_cls
123+
124+
class AliasedCustomGroup(AliasedGroup, base_cls): # type: ignore[valid-type,misc]
125+
"""Hybrid group that adds alias handling to a custom group class."""
126+
127+
AliasedCustomGroup.__name__ = f"Aliased{base_cls.__name__}"
128+
return AliasedCustomGroup
129+
130+
131+
def group(
132+
name: Optional[str] = None,
133+
cls: Optional[type[click.Group]] = None,
134+
**attrs: Any,
135+
) -> Callable[[Callable[P, Any]], click.Group]:
136+
"""Wrapper around ``click.group`` with alias support."""
137+
138+
aliases = attrs.get("aliases")
139+
group_cls = _alias_enabled_group_class(cls, aliases)
140+
return click.group(name=name, cls=group_cls, **attrs)
141+
142+
143+
def command(
144+
name: Optional[str] = None,
145+
cls: Optional[type[click.Command]] = None,
146+
**attrs: Any,
147+
) -> Callable[[Callable[P, Any]], click.Command]:
148+
"""Wrapper around ``click.command`` that preserves aliases on plain Click."""
149+
150+
target_cls = cls or click.Command
151+
aliases = attrs.pop("aliases", None)
152+
153+
if aliases and not _supports_aliases_param(target_cls):
154+
155+
def decorator(func: Callable[P, Any]) -> click.Command:
156+
cmd = click.command(name=name, cls=target_cls, **attrs)(func)
157+
cmd.aliases = tuple(aliases) # type: ignore[attr-defined]
158+
return cmd
159+
160+
return decorator
161+
162+
return click.command(name=name, cls=target_cls, **attrs)

0 commit comments

Comments
 (0)