mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-03-01 17:00:48 +01:00
feat: simplify CLI feature (#23)
* chore: cleanup + add tests * chore: remove graph and show fixtures commands * feat: add async_command wrapper
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"""CLI for FastAPI projects."""
|
||||
|
||||
from .app import app, register_command
|
||||
from .app import cli
|
||||
from .utils import async_command
|
||||
|
||||
__all__ = ["app", "register_command"]
|
||||
__all__ = ["async_command", "cli"]
|
||||
|
||||
@@ -1,97 +1,25 @@
|
||||
"""Main CLI application."""
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
from .commands import fixtures
|
||||
from .config import load_config
|
||||
|
||||
app = typer.Typer(
|
||||
name="fastapi-utils",
|
||||
cli = typer.Typer(
|
||||
name="manager",
|
||||
help="CLI utilities for FastAPI projects.",
|
||||
no_args_is_help=True,
|
||||
)
|
||||
|
||||
# Register built-in commands
|
||||
app.add_typer(fixtures.app, name="fixtures")
|
||||
_config = load_config()
|
||||
|
||||
if _config.fixtures:
|
||||
from .commands.fixtures import fixture_cli
|
||||
|
||||
cli.add_typer(fixture_cli, name="fixtures")
|
||||
|
||||
|
||||
def register_command(command: typer.Typer, name: str) -> None:
|
||||
"""Register a custom command group.
|
||||
|
||||
Args:
|
||||
command: Typer app for the command group
|
||||
name: Name for the command group
|
||||
|
||||
Example:
|
||||
# In your project's cli.py:
|
||||
import typer
|
||||
from fastapi_toolsets.cli import app, register_command
|
||||
|
||||
my_commands = typer.Typer()
|
||||
|
||||
@my_commands.command()
|
||||
def seed():
|
||||
'''Seed the database.'''
|
||||
...
|
||||
|
||||
register_command(my_commands, "db")
|
||||
# Now available as: fastapi-utils db seed
|
||||
"""
|
||||
app.add_typer(command, name=name)
|
||||
|
||||
|
||||
@app.callback()
|
||||
def main(
|
||||
ctx: typer.Context,
|
||||
config: Annotated[
|
||||
Path | None,
|
||||
typer.Option(
|
||||
"--config",
|
||||
"-c",
|
||||
help="Path to project config file (Python module with fixtures registry).",
|
||||
envvar="FASTAPI_TOOLSETS_CONFIG",
|
||||
),
|
||||
] = None,
|
||||
) -> None:
|
||||
@cli.callback()
|
||||
def main(ctx: typer.Context) -> None:
|
||||
"""FastAPI utilities CLI."""
|
||||
ctx.ensure_object(dict)
|
||||
|
||||
if config:
|
||||
ctx.obj["config_path"] = config
|
||||
# Load the config module
|
||||
config_module = _load_module_from_path(config)
|
||||
ctx.obj["config_module"] = config_module
|
||||
|
||||
|
||||
def _load_module_from_path(path: Path) -> object:
|
||||
"""Load a Python module from a file path.
|
||||
|
||||
Handles both absolute and relative imports by adding the config's
|
||||
parent directory to sys.path temporarily.
|
||||
"""
|
||||
path = path.resolve()
|
||||
|
||||
# Add the parent directory to sys.path to support relative imports
|
||||
parent_dir = str(
|
||||
path.parent.parent
|
||||
) # Go up two levels (e.g., from app/cli_config.py to project root)
|
||||
if parent_dir not in sys.path:
|
||||
sys.path.insert(0, parent_dir)
|
||||
|
||||
# Also add immediate parent for direct module imports
|
||||
immediate_parent = str(path.parent)
|
||||
if immediate_parent not in sys.path:
|
||||
sys.path.insert(0, immediate_parent)
|
||||
|
||||
spec = importlib.util.spec_from_file_location("config", path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise typer.BadParameter(f"Cannot load module from {path}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules["config"] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
ctx.obj["config"] = _config
|
||||
|
||||
@@ -1,55 +1,29 @@
|
||||
"""Fixture management commands."""
|
||||
|
||||
import asyncio
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from ...fixtures import Context, FixtureRegistry, LoadStrategy, load_fixtures_by_context
|
||||
from ...fixtures import Context, LoadStrategy, load_fixtures_by_context
|
||||
from ..config import CliConfig
|
||||
from ..utils import async_command
|
||||
|
||||
app = typer.Typer(
|
||||
fixture_cli = typer.Typer(
|
||||
name="fixtures",
|
||||
help="Manage database fixtures.",
|
||||
no_args_is_help=True,
|
||||
)
|
||||
console = Console()
|
||||
|
||||
|
||||
def _get_registry(ctx: typer.Context) -> FixtureRegistry:
|
||||
"""Get fixture registry from context."""
|
||||
config = ctx.obj.get("config_module") if ctx.obj else None
|
||||
if config is None:
|
||||
raise typer.BadParameter(
|
||||
"No config provided. Use --config to specify a config file with a 'fixtures' registry."
|
||||
)
|
||||
|
||||
registry = getattr(config, "fixtures", None)
|
||||
if registry is None:
|
||||
raise typer.BadParameter(
|
||||
"Config module must have a 'fixtures' attribute (FixtureRegistry instance)."
|
||||
)
|
||||
|
||||
if not isinstance(registry, FixtureRegistry):
|
||||
raise typer.BadParameter(
|
||||
f"'fixtures' must be a FixtureRegistry instance, got {type(registry).__name__}"
|
||||
)
|
||||
|
||||
return registry
|
||||
def _get_config(ctx: typer.Context) -> CliConfig:
|
||||
"""Get CLI config from context."""
|
||||
return ctx.obj["config"]
|
||||
|
||||
|
||||
def _get_db_context(ctx: typer.Context):
|
||||
"""Get database context manager from config."""
|
||||
config = ctx.obj.get("config_module") if ctx.obj else None
|
||||
if config is None:
|
||||
raise typer.BadParameter("No config provided.")
|
||||
|
||||
get_db_context = getattr(config, "get_db_context", None)
|
||||
if get_db_context is None:
|
||||
raise typer.BadParameter("Config module must have a 'get_db_context' function.")
|
||||
|
||||
return get_db_context
|
||||
|
||||
|
||||
@app.command("list")
|
||||
@fixture_cli.command("list")
|
||||
def list_fixtures(
|
||||
ctx: typer.Context,
|
||||
context: Annotated[
|
||||
@@ -62,64 +36,28 @@ def list_fixtures(
|
||||
] = None,
|
||||
) -> None:
|
||||
"""List all registered fixtures."""
|
||||
registry = _get_registry(ctx)
|
||||
|
||||
if context:
|
||||
fixtures = registry.get_by_context(context)
|
||||
else:
|
||||
fixtures = registry.get_all()
|
||||
config = _get_config(ctx)
|
||||
registry = config.get_fixtures_registry()
|
||||
fixtures = registry.get_by_context(context) if context else registry.get_all()
|
||||
|
||||
if not fixtures:
|
||||
typer.echo("No fixtures found.")
|
||||
print("No fixtures found.")
|
||||
return
|
||||
|
||||
typer.echo(f"\n{'Name':<30} {'Contexts':<30} {'Dependencies'}")
|
||||
typer.echo("-" * 80)
|
||||
table = Table("Name", "Contexts", "Dependencies")
|
||||
|
||||
for fixture in fixtures:
|
||||
contexts = ", ".join(fixture.contexts)
|
||||
deps = ", ".join(fixture.depends_on) if fixture.depends_on else "-"
|
||||
typer.echo(f"{fixture.name:<30} {contexts:<30} {deps}")
|
||||
table.add_row(fixture.name, contexts, deps)
|
||||
|
||||
typer.echo(f"\nTotal: {len(fixtures)} fixture(s)")
|
||||
console.print(table)
|
||||
print(f"\nTotal: {len(fixtures)} fixture(s)")
|
||||
|
||||
|
||||
@app.command("graph")
|
||||
def show_graph(
|
||||
ctx: typer.Context,
|
||||
fixture_name: Annotated[
|
||||
str | None,
|
||||
typer.Argument(help="Show dependencies for a specific fixture."),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Show fixture dependency graph."""
|
||||
registry = _get_registry(ctx)
|
||||
|
||||
if fixture_name:
|
||||
try:
|
||||
order = registry.resolve_dependencies(fixture_name)
|
||||
typer.echo(f"\nDependency chain for '{fixture_name}':\n")
|
||||
for i, name in enumerate(order):
|
||||
indent = " " * i
|
||||
arrow = "└─> " if i > 0 else ""
|
||||
typer.echo(f"{indent}{arrow}{name}")
|
||||
except KeyError:
|
||||
typer.echo(f"Fixture '{fixture_name}' not found.", err=True)
|
||||
raise typer.Exit(1)
|
||||
else:
|
||||
# Show full graph
|
||||
fixtures = registry.get_all()
|
||||
|
||||
typer.echo("\nFixture Dependency Graph:\n")
|
||||
for fixture in fixtures:
|
||||
deps = (
|
||||
f" -> [{', '.join(fixture.depends_on)}]" if fixture.depends_on else ""
|
||||
)
|
||||
typer.echo(f" {fixture.name}{deps}")
|
||||
|
||||
|
||||
@app.command("load")
|
||||
def load(
|
||||
@fixture_cli.command("load")
|
||||
@async_command
|
||||
async def load(
|
||||
ctx: typer.Context,
|
||||
contexts: Annotated[
|
||||
list[str] | None,
|
||||
@@ -141,16 +79,12 @@ def load(
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Load fixtures into the database."""
|
||||
registry = _get_registry(ctx)
|
||||
get_db_context = _get_db_context(ctx)
|
||||
config = _get_config(ctx)
|
||||
registry = config.get_fixtures_registry()
|
||||
get_db_context = config.get_db_context()
|
||||
|
||||
# Parse contexts
|
||||
if contexts:
|
||||
context_list = contexts
|
||||
else:
|
||||
context_list = [Context.BASE]
|
||||
context_list = contexts if contexts else [Context.BASE]
|
||||
|
||||
# Parse strategy
|
||||
try:
|
||||
load_strategy = LoadStrategy(strategy)
|
||||
except ValueError:
|
||||
@@ -159,67 +93,27 @@ def load(
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Resolve what will be loaded
|
||||
ordered = registry.resolve_context_dependencies(*context_list)
|
||||
|
||||
if not ordered:
|
||||
typer.echo("No fixtures to load for the specified context(s).")
|
||||
print("No fixtures to load for the specified context(s).")
|
||||
return
|
||||
|
||||
typer.echo(f"\nFixtures to load ({load_strategy.value} strategy):")
|
||||
print(f"\nFixtures to load ({load_strategy.value} strategy):")
|
||||
for name in ordered:
|
||||
fixture = registry.get(name)
|
||||
instances = list(fixture.func())
|
||||
model_name = type(instances[0]).__name__ if instances else "?"
|
||||
typer.echo(f" - {name}: {len(instances)} {model_name}(s)")
|
||||
print(f" - {name}: {len(instances)} {model_name}(s)")
|
||||
|
||||
if dry_run:
|
||||
typer.echo("\n[Dry run - no changes made]")
|
||||
print("\n[Dry run - no changes made]")
|
||||
return
|
||||
|
||||
typer.echo("\nLoading...")
|
||||
|
||||
async def do_load():
|
||||
async with get_db_context() as session:
|
||||
result = await load_fixtures_by_context(
|
||||
session, registry, *context_list, strategy=load_strategy
|
||||
)
|
||||
return result
|
||||
|
||||
result = asyncio.run(do_load())
|
||||
async with get_db_context() as session:
|
||||
result = await load_fixtures_by_context(
|
||||
session, registry, *context_list, strategy=load_strategy
|
||||
)
|
||||
|
||||
total = sum(len(items) for items in result.values())
|
||||
typer.echo(f"\nLoaded {total} record(s) successfully.")
|
||||
|
||||
|
||||
@app.command("show")
|
||||
def show_fixture(
|
||||
ctx: typer.Context,
|
||||
name: Annotated[str, typer.Argument(help="Fixture name to show.")],
|
||||
) -> None:
|
||||
"""Show details of a specific fixture."""
|
||||
registry = _get_registry(ctx)
|
||||
|
||||
try:
|
||||
fixture = registry.get(name)
|
||||
except KeyError:
|
||||
typer.echo(f"Fixture '{name}' not found.", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
typer.echo(f"\nFixture: {fixture.name}")
|
||||
typer.echo(f"Contexts: {', '.join(fixture.contexts)}")
|
||||
typer.echo(
|
||||
f"Dependencies: {', '.join(fixture.depends_on) if fixture.depends_on else 'None'}"
|
||||
)
|
||||
|
||||
# Show instances
|
||||
instances = list(fixture.func())
|
||||
if instances:
|
||||
model_name = type(instances[0]).__name__
|
||||
typer.echo(f"\nInstances ({len(instances)} {model_name}):")
|
||||
for instance in instances[:10]: # Limit to 10
|
||||
typer.echo(f" - {instance!r}")
|
||||
if len(instances) > 10:
|
||||
typer.echo(f" ... and {len(instances) - 10} more")
|
||||
else:
|
||||
typer.echo("\nNo instances (empty fixture)")
|
||||
print(f"\nLoaded {total} record(s) successfully.")
|
||||
|
||||
92
src/fastapi_toolsets/cli/config.py
Normal file
92
src/fastapi_toolsets/cli/config.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""CLI configuration."""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
||||
|
||||
@dataclass
|
||||
class CliConfig:
|
||||
"""CLI configuration loaded from pyproject.toml."""
|
||||
|
||||
fixtures: str | None = None
|
||||
db_context: str | None = None
|
||||
|
||||
def get_fixtures_registry(self):
|
||||
"""Import and return the fixtures registry."""
|
||||
from ..fixtures import FixtureRegistry
|
||||
|
||||
if not self.fixtures:
|
||||
raise typer.BadParameter(
|
||||
"No fixtures registry configured. "
|
||||
"Add 'fixtures' to [tool.fastapi-toolsets] in pyproject.toml."
|
||||
)
|
||||
|
||||
registry = _import_from_string(self.fixtures)
|
||||
|
||||
if not isinstance(registry, FixtureRegistry):
|
||||
raise typer.BadParameter(
|
||||
f"'fixtures' must be a FixtureRegistry instance, got {type(registry).__name__}"
|
||||
)
|
||||
|
||||
return registry
|
||||
|
||||
def get_db_context(self):
|
||||
"""Import and return the db_context function."""
|
||||
if not self.db_context:
|
||||
raise typer.BadParameter(
|
||||
"No db_context configured. "
|
||||
"Add 'db_context' to [tool.fastapi-toolsets] in pyproject.toml."
|
||||
)
|
||||
return _import_from_string(self.db_context)
|
||||
|
||||
|
||||
def _import_from_string(import_path: str):
|
||||
"""Import an object from a string path like 'module.submodule:attribute'."""
|
||||
if ":" not in import_path:
|
||||
raise typer.BadParameter(
|
||||
f"Invalid import path '{import_path}'. Expected format: 'module:attribute'"
|
||||
)
|
||||
|
||||
module_path, attr_name = import_path.rsplit(":", 1)
|
||||
|
||||
# Add cwd to sys.path for local imports
|
||||
cwd = str(Path.cwd())
|
||||
if cwd not in sys.path:
|
||||
sys.path.insert(0, cwd)
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_path)
|
||||
except ImportError as e:
|
||||
raise typer.BadParameter(f"Cannot import module '{module_path}': {e}")
|
||||
|
||||
if not hasattr(module, attr_name):
|
||||
raise typer.BadParameter(
|
||||
f"Module '{module_path}' has no attribute '{attr_name}'"
|
||||
)
|
||||
|
||||
return getattr(module, attr_name)
|
||||
|
||||
|
||||
def load_config() -> CliConfig:
|
||||
"""Load CLI configuration from pyproject.toml."""
|
||||
pyproject_path = Path.cwd() / "pyproject.toml"
|
||||
|
||||
if not pyproject_path.exists():
|
||||
return CliConfig()
|
||||
|
||||
try:
|
||||
with open(pyproject_path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
|
||||
tool_config = data.get("tool", {}).get("fastapi-toolsets", {})
|
||||
return CliConfig(
|
||||
fixtures=tool_config.get("fixtures"),
|
||||
db_context=tool_config.get("db_context"),
|
||||
)
|
||||
except Exception:
|
||||
return CliConfig()
|
||||
27
src/fastapi_toolsets/cli/utils.py
Normal file
27
src/fastapi_toolsets/cli/utils.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""CLI utility functions."""
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any, ParamSpec, TypeVar
|
||||
|
||||
P = ParamSpec("P")
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def async_command(func: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, T]:
|
||||
"""Decorator to run an async function as a sync CLI command.
|
||||
|
||||
Example:
|
||||
@fixture_cli.command("load")
|
||||
@async_command
|
||||
async def load(ctx: typer.Context) -> None:
|
||||
async with get_db_context() as session:
|
||||
await load_fixtures(session, registry)
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
||||
return asyncio.run(func(*args, **kwargs))
|
||||
|
||||
return wrapper
|
||||
Reference in New Issue
Block a user