Skip to content

Commit 058848d

Browse files
authored
fix(fastapi): support Typer 0.26 CLI registration (#748)
## Summary - Updates the FastAPI CLI integration for Typer 0.26+, where Typer vendors Click and no longer accepts external `click.Group` objects through `TyperGroup.add_command()`. - Keeps `register_database_commands(app)` returning the existing Click migration command group, matching the existing adapter naming pattern. - Routes FastAPI CLI registration through `assign_cli_group(app)`, with an internal Typer bridge that exposes both `database` and `db` and delegates to the existing Click commands. - Updates FastAPI examples to use the public `assign_cli_group(app)` adapter surface. - Includes the current dependency/version bump that exposed the Typer 0.26 compatibility issue.
1 parent f51e16c commit 058848d

9 files changed

Lines changed: 117 additions & 22 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,9 @@ jobs:
180180
- name: Install dependencies
181181
run: uv sync --all-extras --dev
182182

183+
- name: Test docs examples
184+
run: uv run make docs-test
185+
183186
- name: Build docs
184187
run: uv run make docs
185188

advanced_alchemy/extensions/fastapi/extension.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,36 @@
3838

3939
def assign_cli_group(app: "FastAPI") -> None: # pragma: no cover
4040
try:
41+
import typer
42+
from click import ClickException
43+
from click.exceptions import Exit as ClickExit
4144
from fastapi_cli.cli import app as fastapi_cli_app # pyright: ignore[reportUnknownVariableType]
42-
from typer.main import get_group
4345
except ImportError:
4446
print("FastAPI CLI is not installed. Skipping CLI registration.") # noqa: T201
4547
return
46-
click_app = get_group(fastapi_cli_app) # pyright: ignore[reportUnknownArgumentType]
47-
click_app.add_command(register_database_commands(app))
48+
49+
def run_database_command(ctx: typer.Context) -> None:
50+
database_group = register_database_commands(app)
51+
args = list(ctx.args) or ["--help"]
52+
try:
53+
database_group.main(
54+
args=args,
55+
prog_name=ctx.info_name or database_group.name,
56+
standalone_mode=False,
57+
)
58+
except ClickExit as e:
59+
raise typer.Exit(e.exit_code) from e
60+
except ClickException as e:
61+
e.show()
62+
raise typer.Exit(e.exit_code) from e
63+
64+
for name in ("database", "db"):
65+
fastapi_cli_app.command(
66+
name=name,
67+
help="Manage SQLAlchemy database components.",
68+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True, "help_option_names": []},
69+
add_help_option=False,
70+
)(run_database_command)
4871

4972

5073
class AdvancedAlchemy(StarletteAdvancedAlchemy):

docs/PYPI_README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ offering:
3838
- Unified interface for various storage backends ([`fsspec`](https://filesystem-spec.readthedocs.io/en/latest/) and [`obstore`](https://developmentseed.org/obstore/latest/))
3939
- Optional lifecycle event hooks integrated with SQLAlchemy's event system to automatically save and delete files as records are inserted, updated, or deleted.
4040
- Optimized JSON types including a custom JSON type for Oracle
41-
- Integrated support for UUID6 and UUID7 using [`uuid-utils`](https://github.com/aminalaee/uuid-utils) (install with the `uuid` extra)
41+
- Integrated support for UUID6 and UUID7 ([`uuid-utils`](https://github.com/aminalaee/uuid-utils) when installed, otherwise native in Python 3.14+, with UUID4 fallback on older versions).
4242
- Integrated support for Nano ID using [`fastnanoid`](https://github.com/oliverlambson/fastnanoid) (install with the `nanoid` extra)
4343
- Custom encrypted text type with multiple backend support including [`pgcrypto`](https://www.postgresql.org/docs/current/pgcrypto.html) for PostgreSQL and the Fernet implementation from [`cryptography`](https://cryptography.io/en/latest/) for other databases
4444
- Custom password hashing type with multiple backend support including [`Argon2`](https://github.com/P-H-C/phc-winner-argon2), [`Passlib`](https://passlib.readthedocs.io/en/stable/), and [`Pwdlib`](https://pwdlib.readthedocs.io/en/stable/) with automatic salt generation

examples/fastapi/fastapi_filters.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,8 @@ async def list_authors(
8585
Run `uv run examples/fastapi/fastapi_service.py` to launch the FastAPI CLI with the database commands registered
8686
"""
8787
from fastapi_cli.cli import app as fastapi_cli_app # pyright: ignore[reportUnknownVariableType]
88-
from typer.main import get_group
8988

90-
from advanced_alchemy.extensions.fastapi.cli import register_database_commands
89+
from advanced_alchemy.extensions.fastapi import assign_cli_group
9190

92-
click_app = get_group(fastapi_cli_app) # pyright: ignore[reportUnknownArgumentType]
93-
click_app.add_command(register_database_commands(app))
94-
click_app()
91+
assign_cli_group(app)
92+
fastapi_cli_app()

examples/fastapi/fastapi_service.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,8 @@ async def delete_author(
140140
Run `uv run examples/fastapi/fastapi_service.py --help` to launch the FastAPI CLI with the database commands registered
141141
"""
142142
from fastapi_cli.cli import app as fastapi_cli_app # pyright: ignore[reportUnknownVariableType]
143-
from typer.main import get_group
144143

145-
from advanced_alchemy.extensions.fastapi.cli import register_database_commands
144+
from advanced_alchemy.extensions.fastapi import assign_cli_group
146145

147-
click_app = get_group(fastapi_cli_app) # pyright: ignore[reportUnknownArgumentType]
148-
click_app.add_command(register_database_commands(app))
149-
click_app()
146+
assign_cli_group(app)
147+
fastapi_cli_app()

examples/fastapi/fastapi_service_full.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,8 @@ async def delete_author(
191191
Run `uv run examples/fastapi/fastapi_service.py --help` to launch the FastAPI CLI with the database commands registered
192192
"""
193193
from fastapi_cli.cli import app as fastapi_cli_app # pyright: ignore[reportUnknownVariableType]
194-
from typer.main import get_group
195194

196-
from advanced_alchemy.extensions.fastapi.cli import register_database_commands
195+
from advanced_alchemy.extensions.fastapi import assign_cli_group
197196

198-
click_app = get_group(fastapi_cli_app) # pyright: ignore[reportUnknownArgumentType]
199-
click_app.add_command(register_database_commands(app))
200-
click_app()
197+
assign_cli_group(app)
198+
fastapi_cli_app()

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ maintainers = [
4646
name = "advanced_alchemy"
4747
readme = "docs/PYPI_README.md"
4848
requires-python = ">=3.9"
49-
version = "1.10.0"
49+
version = "1.11.0"
5050

5151
[project.urls]
5252
Changelog = "https://advanced-alchemy.litestar.dev/latest/changelog"
@@ -178,7 +178,7 @@ test = [
178178
allow_dirty = true
179179
commit = false
180180
commit_args = "--no-verify"
181-
current_version = "1.10.0"
181+
current_version = "1.11.0"
182182
ignore_missing_files = false
183183
ignore_missing_version = false
184184
message = "chore(release): bump to v{new_version}"

tests/unit/test_extensions/test_fastapi/test_extension.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
import sys
2+
import types
23
from collections.abc import AsyncGenerator, Generator
34
from contextlib import asynccontextmanager
45
from typing import TYPE_CHECKING, Annotated, Callable, Literal, Union, cast
56
from unittest.mock import MagicMock
67

8+
import click as base_click
79
import pytest
10+
import typer
811
from fastapi import Depends, FastAPI, HTTPException, Request, Response
912
from fastapi.testclient import TestClient
1013
from pytest import FixtureRequest
1114
from pytest_mock import MockerFixture
1215
from sqlalchemy import Engine
1316
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
1417
from sqlalchemy.orm import Session
18+
from typer.testing import CliRunner
1519
from typing_extensions import assert_type
1620

1721
from advanced_alchemy.exceptions import ImproperConfigurationError
@@ -55,6 +59,77 @@ def alchemy(config: AnyConfig, app: FastAPI) -> AdvancedAlchemy:
5559
return AdvancedAlchemy(config, app=app)
5660

5761

62+
def _install_fastapi_cli_app(monkeypatch: pytest.MonkeyPatch, typer_app: typer.Typer) -> None:
63+
fastapi_cli = types.ModuleType("fastapi_cli")
64+
fastapi_cli_cli = types.ModuleType("fastapi_cli.cli")
65+
fastapi_cli_cli.app = typer_app
66+
setattr(fastapi_cli, "cli", fastapi_cli_cli)
67+
monkeypatch.setitem(sys.modules, "fastapi_cli", fastapi_cli)
68+
monkeypatch.setitem(sys.modules, "fastapi_cli.cli", fastapi_cli_cli)
69+
70+
71+
def test_assign_cli_group_delegates_database_and_db(monkeypatch: pytest.MonkeyPatch) -> None:
72+
from advanced_alchemy.extensions.fastapi import extension as fastapi_extension
73+
from advanced_alchemy.extensions.fastapi.extension import assign_cli_group
74+
75+
seen: list[tuple[FastAPI, str]] = []
76+
77+
def fake_register_database_commands(app: FastAPI) -> base_click.Group:
78+
@base_click.group(name="database")
79+
def database_group() -> None:
80+
pass
81+
82+
@database_group.command(name="upgrade")
83+
@base_click.argument("revision", default="head")
84+
def upgrade_database(revision: str) -> None:
85+
seen.append((app, revision))
86+
87+
return database_group
88+
89+
monkeypatch.setattr(fastapi_extension, "register_database_commands", fake_register_database_commands)
90+
app = FastAPI()
91+
typer_app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]})
92+
_install_fastapi_cli_app(monkeypatch, typer_app)
93+
94+
assign_cli_group(app)
95+
96+
runner = CliRunner()
97+
result = runner.invoke(typer_app, ["database", "upgrade", "abc123"])
98+
alias_result = runner.invoke(typer_app, ["db", "upgrade"])
99+
100+
assert result.exit_code == 0, result.output
101+
assert alias_result.exit_code == 0, alias_result.output
102+
assert seen == [(app, "abc123"), (app, "head")]
103+
104+
105+
def test_assign_cli_group_delegates_database_help(monkeypatch: pytest.MonkeyPatch) -> None:
106+
from advanced_alchemy.extensions.fastapi import extension as fastapi_extension
107+
from advanced_alchemy.extensions.fastapi.extension import assign_cli_group
108+
109+
def fake_register_database_commands(app: FastAPI) -> base_click.Group:
110+
@base_click.group(name="database")
111+
def database_group() -> None:
112+
pass
113+
114+
@database_group.command(name="upgrade")
115+
def upgrade_database() -> None:
116+
pass
117+
118+
return database_group
119+
120+
monkeypatch.setattr(fastapi_extension, "register_database_commands", fake_register_database_commands)
121+
app = FastAPI()
122+
typer_app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]})
123+
_install_fastapi_cli_app(monkeypatch, typer_app)
124+
assign_cli_group(app)
125+
126+
result = CliRunner().invoke(typer_app, ["database", "--help"])
127+
128+
assert result.exit_code == 0, result.output
129+
assert "Commands:" in result.output
130+
assert "upgrade" in result.output
131+
132+
58133
async def test_infer_types_from_config(async_config: SQLAlchemyAsyncConfig, sync_config: SQLAlchemySyncConfig) -> None:
59134
if TYPE_CHECKING:
60135
alchemy = AdvancedAlchemy(config=[async_config, sync_config])

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)