mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-03-01 17:00:48 +01:00
feat: allow custom CLI (#28)
This commit is contained in:
@@ -28,7 +28,7 @@ uv add fastapi-toolsets
|
|||||||
|
|
||||||
- **CRUD**: Generic async CRUD operations with `CrudFactory`
|
- **CRUD**: Generic async CRUD operations with `CrudFactory`
|
||||||
- **Fixtures**: Fixture system with dependency management, context support and pytest integration
|
- **Fixtures**: Fixture system with dependency management, context support and pytest integration
|
||||||
- **CLI**: Django-like command-line interface for fixtures and custom commands
|
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
||||||
- **Standardized API Responses**: Consistent response format across your API
|
- **Standardized API Responses**: Consistent response format across your API
|
||||||
- **Exception Handling**: Structured error responses with automatic OpenAPI documentation
|
- **Exception Handling**: Structured error responses with automatic OpenAPI documentation
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""CLI for FastAPI projects."""
|
"""CLI for FastAPI projects."""
|
||||||
|
|
||||||
from .app import cli
|
|
||||||
from .utils import async_command
|
from .utils import async_command
|
||||||
|
|
||||||
__all__ = ["async_command", "cli"]
|
__all__ = ["async_command"]
|
||||||
|
|||||||
@@ -2,17 +2,23 @@
|
|||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
from .config import load_config
|
from .config import get_custom_cli
|
||||||
|
from .pyproject import load_pyproject
|
||||||
|
|
||||||
cli = typer.Typer(
|
# Use custom CLI if configured, otherwise create default one
|
||||||
|
_custom_cli = get_custom_cli()
|
||||||
|
|
||||||
|
if _custom_cli is not None:
|
||||||
|
cli = _custom_cli
|
||||||
|
else:
|
||||||
|
cli = typer.Typer(
|
||||||
name="manager",
|
name="manager",
|
||||||
help="CLI utilities for FastAPI projects.",
|
help="CLI utilities for FastAPI projects.",
|
||||||
no_args_is_help=True,
|
no_args_is_help=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
_config = load_config()
|
_config = load_pyproject()
|
||||||
|
if _config.get("fixtures") and _config.get("db_context"):
|
||||||
if _config.fixtures:
|
|
||||||
from .commands.fixtures import fixture_cli
|
from .commands.fixtures import fixture_cli
|
||||||
|
|
||||||
cli.add_typer(fixture_cli, name="fixtures")
|
cli.add_typer(fixture_cli, name="fixtures")
|
||||||
@@ -22,4 +28,3 @@ if _config.fixtures:
|
|||||||
def main(ctx: typer.Context) -> None:
|
def main(ctx: typer.Context) -> None:
|
||||||
"""FastAPI utilities CLI."""
|
"""FastAPI utilities CLI."""
|
||||||
ctx.ensure_object(dict)
|
ctx.ensure_object(dict)
|
||||||
ctx.obj["config"] = _config
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from rich.console import Console
|
|||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
from ...fixtures import Context, LoadStrategy, load_fixtures_by_context
|
from ...fixtures import Context, LoadStrategy, load_fixtures_by_context
|
||||||
from ..config import CliConfig
|
from ..config import get_db_context, get_fixtures_registry
|
||||||
from ..utils import async_command
|
from ..utils import async_command
|
||||||
|
|
||||||
fixture_cli = typer.Typer(
|
fixture_cli = typer.Typer(
|
||||||
@@ -18,27 +18,21 @@ fixture_cli = typer.Typer(
|
|||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
def _get_config(ctx: typer.Context) -> CliConfig:
|
|
||||||
"""Get CLI config from context."""
|
|
||||||
return ctx.obj["config"]
|
|
||||||
|
|
||||||
|
|
||||||
@fixture_cli.command("list")
|
@fixture_cli.command("list")
|
||||||
def list_fixtures(
|
def list_fixtures(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
context: Annotated[
|
context: Annotated[
|
||||||
str | None,
|
Context | None,
|
||||||
typer.Option(
|
typer.Option(
|
||||||
"--context",
|
"--context",
|
||||||
"-c",
|
"-c",
|
||||||
help="Filter by context (base, production, development, testing).",
|
help="Filter by context.",
|
||||||
),
|
),
|
||||||
] = None,
|
] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""List all registered fixtures."""
|
"""List all registered fixtures."""
|
||||||
config = _get_config(ctx)
|
registry = get_fixtures_registry()
|
||||||
registry = config.get_fixtures_registry()
|
fixtures = registry.get_by_context(context.value) if context else registry.get_all()
|
||||||
fixtures = registry.get_by_context(context) if context else registry.get_all()
|
|
||||||
|
|
||||||
if not fixtures:
|
if not fixtures:
|
||||||
print("No fixtures found.")
|
print("No fixtures found.")
|
||||||
@@ -60,17 +54,13 @@ def list_fixtures(
|
|||||||
async def load(
|
async def load(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
contexts: Annotated[
|
contexts: Annotated[
|
||||||
list[str] | None,
|
list[Context] | None,
|
||||||
typer.Argument(
|
typer.Argument(help="Contexts to load."),
|
||||||
help="Contexts to load (base, production, development, testing)."
|
|
||||||
),
|
|
||||||
] = None,
|
] = None,
|
||||||
strategy: Annotated[
|
strategy: Annotated[
|
||||||
str,
|
LoadStrategy,
|
||||||
typer.Option(
|
typer.Option("--strategy", "-s", help="Load strategy."),
|
||||||
"--strategy", "-s", help="Load strategy: merge, insert, skip_existing."
|
] = LoadStrategy.MERGE,
|
||||||
),
|
|
||||||
] = "merge",
|
|
||||||
dry_run: Annotated[
|
dry_run: Annotated[
|
||||||
bool,
|
bool,
|
||||||
typer.Option(
|
typer.Option(
|
||||||
@@ -79,19 +69,10 @@ async def load(
|
|||||||
] = False,
|
] = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Load fixtures into the database."""
|
"""Load fixtures into the database."""
|
||||||
config = _get_config(ctx)
|
registry = get_fixtures_registry()
|
||||||
registry = config.get_fixtures_registry()
|
db_context = get_db_context()
|
||||||
get_db_context = config.get_db_context()
|
|
||||||
|
|
||||||
context_list = contexts if contexts else [Context.BASE]
|
context_list = [c.value for c in contexts] if contexts else [Context.BASE]
|
||||||
|
|
||||||
try:
|
|
||||||
load_strategy = LoadStrategy(strategy)
|
|
||||||
except ValueError:
|
|
||||||
typer.echo(
|
|
||||||
f"Invalid strategy: {strategy}. Use: merge, insert, skip_existing", err=True
|
|
||||||
)
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
ordered = registry.resolve_context_dependencies(*context_list)
|
ordered = registry.resolve_context_dependencies(*context_list)
|
||||||
|
|
||||||
@@ -99,7 +80,7 @@ async def load(
|
|||||||
print("No fixtures to load for the specified context(s).")
|
print("No fixtures to load for the specified context(s).")
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"\nFixtures to load ({load_strategy.value} strategy):")
|
print(f"\nFixtures to load ({strategy.value} strategy):")
|
||||||
for name in ordered:
|
for name in ordered:
|
||||||
fixture = registry.get(name)
|
fixture = registry.get(name)
|
||||||
instances = list(fixture.func())
|
instances = list(fixture.func())
|
||||||
@@ -110,9 +91,9 @@ async def load(
|
|||||||
print("\n[Dry run - no changes made]")
|
print("\n[Dry run - no changes made]")
|
||||||
return
|
return
|
||||||
|
|
||||||
async with get_db_context() as session:
|
async with db_context() as session:
|
||||||
result = await load_fixtures_by_context(
|
result = await load_fixtures_by_context(
|
||||||
session, registry, *context_list, strategy=load_strategy
|
session, registry, *context_list, strategy=strategy
|
||||||
)
|
)
|
||||||
|
|
||||||
total = sum(len(items) for items in result.values())
|
total = sum(len(items) for items in result.values())
|
||||||
|
|||||||
@@ -1,52 +1,28 @@
|
|||||||
"""CLI configuration."""
|
"""CLI configuration and dynamic imports."""
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import sys
|
import sys
|
||||||
import tomllib
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
|
from .pyproject import find_pyproject, load_pyproject
|
||||||
@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):
|
def _ensure_project_in_path():
|
||||||
"""Import an object from a string path like 'module.submodule:attribute'."""
|
"""Add project root to sys.path if not installed in editable mode."""
|
||||||
|
pyproject = find_pyproject()
|
||||||
|
if pyproject:
|
||||||
|
project_root = str(pyproject.parent)
|
||||||
|
if project_root not in sys.path:
|
||||||
|
sys.path.insert(0, project_root)
|
||||||
|
|
||||||
|
|
||||||
|
def import_from_string(import_path: str):
|
||||||
|
"""Import an object from a string path like 'module.submodule:attribute'.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
typer.BadParameter: If the import path is invalid or import fails.
|
||||||
|
"""
|
||||||
if ":" not in import_path:
|
if ":" not in import_path:
|
||||||
raise typer.BadParameter(
|
raise typer.BadParameter(
|
||||||
f"Invalid import path '{import_path}'. Expected format: 'module:attribute'"
|
f"Invalid import path '{import_path}'. Expected format: 'module:attribute'"
|
||||||
@@ -54,10 +30,7 @@ def _import_from_string(import_path: str):
|
|||||||
|
|
||||||
module_path, attr_name = import_path.rsplit(":", 1)
|
module_path, attr_name = import_path.rsplit(":", 1)
|
||||||
|
|
||||||
# Add cwd to sys.path for local imports
|
_ensure_project_in_path()
|
||||||
cwd = str(Path.cwd())
|
|
||||||
if cwd not in sys.path:
|
|
||||||
sys.path.insert(0, cwd)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
module = importlib.import_module(module_path)
|
module = importlib.import_module(module_path)
|
||||||
@@ -72,21 +45,63 @@ def _import_from_string(import_path: str):
|
|||||||
return getattr(module, attr_name)
|
return getattr(module, attr_name)
|
||||||
|
|
||||||
|
|
||||||
def load_config() -> CliConfig:
|
def get_config_value(key: str, required: bool = False):
|
||||||
"""Load CLI configuration from pyproject.toml."""
|
"""Get a configuration value from pyproject.toml.
|
||||||
pyproject_path = Path.cwd() / "pyproject.toml"
|
|
||||||
|
|
||||||
if not pyproject_path.exists():
|
Args:
|
||||||
return CliConfig()
|
key: The configuration key in [tool.fastapi-toolsets].
|
||||||
|
required: If True, raises an error when the key is missing.
|
||||||
|
|
||||||
try:
|
Returns:
|
||||||
with open(pyproject_path, "rb") as f:
|
The configuration value, or None if not found and not required.
|
||||||
data = tomllib.load(f)
|
|
||||||
|
|
||||||
tool_config = data.get("tool", {}).get("fastapi-toolsets", {})
|
Raises:
|
||||||
return CliConfig(
|
typer.BadParameter: If required=True and the key is missing.
|
||||||
fixtures=tool_config.get("fixtures"),
|
"""
|
||||||
db_context=tool_config.get("db_context"),
|
config = load_pyproject()
|
||||||
|
value = config.get(key)
|
||||||
|
|
||||||
|
if required and value is None:
|
||||||
|
raise typer.BadParameter(
|
||||||
|
f"No '{key}' configured. "
|
||||||
|
f"Add '{key}' to [tool.fastapi-toolsets] in pyproject.toml."
|
||||||
)
|
)
|
||||||
except Exception:
|
|
||||||
return CliConfig()
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def get_fixtures_registry():
|
||||||
|
"""Import and return the fixtures registry from config."""
|
||||||
|
from ..fixtures import FixtureRegistry
|
||||||
|
|
||||||
|
import_path = get_config_value("fixtures", required=True)
|
||||||
|
registry = import_from_string(import_path)
|
||||||
|
|
||||||
|
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():
|
||||||
|
"""Import and return the db_context function from config."""
|
||||||
|
import_path = get_config_value("db_context", required=True)
|
||||||
|
return import_from_string(import_path)
|
||||||
|
|
||||||
|
|
||||||
|
def get_custom_cli() -> typer.Typer | None:
|
||||||
|
"""Import and return the custom CLI Typer instance from config."""
|
||||||
|
import_path = get_config_value("custom_cli")
|
||||||
|
if not import_path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
custom = import_from_string(import_path)
|
||||||
|
|
||||||
|
if not isinstance(custom, typer.Typer):
|
||||||
|
raise typer.BadParameter(
|
||||||
|
f"'custom_cli' must be a Typer instance, got {type(custom).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return custom
|
||||||
|
|||||||
43
src/fastapi_toolsets/cli/pyproject.py
Normal file
43
src/fastapi_toolsets/cli/pyproject.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Pyproject.toml discovery and loading."""
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TOOL_NAME = "fastapi-toolsets"
|
||||||
|
|
||||||
|
|
||||||
|
def find_pyproject(start_path: Path | None = None) -> Path | None:
|
||||||
|
"""Find pyproject.toml by walking up the directory tree.
|
||||||
|
|
||||||
|
Similar to how pytest, black, and ruff discover their config files.
|
||||||
|
"""
|
||||||
|
path = (start_path or Path.cwd()).resolve()
|
||||||
|
|
||||||
|
for directory in [path, *path.parents]:
|
||||||
|
pyproject = directory / "pyproject.toml"
|
||||||
|
if pyproject.is_file():
|
||||||
|
return pyproject
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_pyproject(path: Path | None = None) -> dict:
|
||||||
|
"""Load tool configuration from pyproject.toml.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Explicit path to pyproject.toml. If None, searches up from cwd.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The [tool.fastapi-toolsets] section as a dict, or empty dict if not found.
|
||||||
|
"""
|
||||||
|
pyproject_path = path or find_pyproject()
|
||||||
|
|
||||||
|
if not pyproject_path:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(pyproject_path, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
return data.get("tool", {}).get(TOOL_NAME, {})
|
||||||
|
except (OSError, tomllib.TOMLDecodeError):
|
||||||
|
return {}
|
||||||
@@ -5,138 +5,234 @@ import sys
|
|||||||
import pytest
|
import pytest
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
from fastapi_toolsets.cli.config import CliConfig, _import_from_string, load_config
|
from fastapi_toolsets.cli.config import (
|
||||||
|
get_config_value,
|
||||||
|
get_custom_cli,
|
||||||
|
get_db_context,
|
||||||
|
get_fixtures_registry,
|
||||||
|
import_from_string,
|
||||||
|
)
|
||||||
|
from fastapi_toolsets.cli.pyproject import find_pyproject, load_pyproject
|
||||||
from fastapi_toolsets.cli.utils import async_command
|
from fastapi_toolsets.cli.utils import async_command
|
||||||
from fastapi_toolsets.fixtures import FixtureRegistry
|
from fastapi_toolsets.fixtures import FixtureRegistry
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
class TestCliConfig:
|
class TestPyproject:
|
||||||
"""Tests for CliConfig dataclass."""
|
"""Tests for pyproject.toml discovery and loading."""
|
||||||
|
|
||||||
def test_default_values(self):
|
def test_find_pyproject_in_current_dir(self, tmp_path, monkeypatch):
|
||||||
"""Config has None defaults."""
|
"""Finds pyproject.toml in current directory."""
|
||||||
config = CliConfig()
|
|
||||||
assert config.fixtures is None
|
|
||||||
assert config.db_context is None
|
|
||||||
|
|
||||||
def test_with_values(self):
|
|
||||||
"""Config stores provided values."""
|
|
||||||
config = CliConfig(
|
|
||||||
fixtures="app.fixtures:registry",
|
|
||||||
db_context="app.db:get_session",
|
|
||||||
)
|
|
||||||
assert config.fixtures == "app.fixtures:registry"
|
|
||||||
assert config.db_context == "app.db:get_session"
|
|
||||||
|
|
||||||
def test_get_fixtures_registry_without_config(self):
|
|
||||||
"""get_fixtures_registry raises error when not configured."""
|
|
||||||
config = CliConfig()
|
|
||||||
with pytest.raises(Exception) as exc_info:
|
|
||||||
config.get_fixtures_registry()
|
|
||||||
assert "No fixtures registry configured" in str(exc_info.value)
|
|
||||||
|
|
||||||
def test_get_db_context_without_config(self):
|
|
||||||
"""get_db_context raises error when not configured."""
|
|
||||||
config = CliConfig()
|
|
||||||
with pytest.raises(Exception) as exc_info:
|
|
||||||
config.get_db_context()
|
|
||||||
assert "No db_context configured" in str(exc_info.value)
|
|
||||||
|
|
||||||
|
|
||||||
class TestImportFromString:
|
|
||||||
"""Tests for _import_from_string function."""
|
|
||||||
|
|
||||||
def test_import_valid_path(self):
|
|
||||||
"""Import valid module:attribute path."""
|
|
||||||
result = _import_from_string("fastapi_toolsets.fixtures:FixtureRegistry")
|
|
||||||
assert result is FixtureRegistry
|
|
||||||
|
|
||||||
def test_import_without_colon_raises_error(self):
|
|
||||||
"""Import path without colon raises error."""
|
|
||||||
with pytest.raises(Exception) as exc_info:
|
|
||||||
_import_from_string("fastapi_toolsets.fixtures.FixtureRegistry")
|
|
||||||
assert "Expected format: 'module:attribute'" in str(exc_info.value)
|
|
||||||
|
|
||||||
def test_import_nonexistent_module_raises_error(self):
|
|
||||||
"""Import nonexistent module raises error."""
|
|
||||||
with pytest.raises(Exception) as exc_info:
|
|
||||||
_import_from_string("nonexistent.module:something")
|
|
||||||
assert "Cannot import module" in str(exc_info.value)
|
|
||||||
|
|
||||||
def test_import_nonexistent_attribute_raises_error(self):
|
|
||||||
"""Import nonexistent attribute raises error."""
|
|
||||||
with pytest.raises(Exception) as exc_info:
|
|
||||||
_import_from_string("fastapi_toolsets.fixtures:NonexistentClass")
|
|
||||||
assert "has no attribute" in str(exc_info.value)
|
|
||||||
|
|
||||||
|
|
||||||
class TestLoadConfig:
|
|
||||||
"""Tests for load_config function."""
|
|
||||||
|
|
||||||
def test_load_without_pyproject(self, tmp_path, monkeypatch):
|
|
||||||
"""Returns empty config when no pyproject.toml exists."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
config = load_config()
|
|
||||||
assert config.fixtures is None
|
|
||||||
assert config.db_context is None
|
|
||||||
|
|
||||||
def test_load_without_tool_section(self, tmp_path, monkeypatch):
|
|
||||||
"""Returns empty config when no [tool.fastapi-toolsets] section."""
|
|
||||||
pyproject = tmp_path / "pyproject.toml"
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
pyproject.write_text("[project]\nname = 'test'\n")
|
pyproject.write_text("[project]\nname = 'test'\n")
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
config = load_config()
|
result = find_pyproject()
|
||||||
assert config.fixtures is None
|
assert result == pyproject
|
||||||
assert config.db_context is None
|
|
||||||
|
|
||||||
def test_load_with_fixtures_config(self, tmp_path, monkeypatch):
|
def test_find_pyproject_in_parent_dir(self, tmp_path, monkeypatch):
|
||||||
"""Loads fixtures config from pyproject.toml."""
|
"""Finds pyproject.toml in parent directory."""
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text("[project]\nname = 'test'\n")
|
||||||
|
subdir = tmp_path / "src" / "app"
|
||||||
|
subdir.mkdir(parents=True)
|
||||||
|
monkeypatch.chdir(subdir)
|
||||||
|
|
||||||
|
result = find_pyproject()
|
||||||
|
assert result == pyproject
|
||||||
|
|
||||||
|
def test_find_pyproject_not_found(self, tmp_path, monkeypatch):
|
||||||
|
"""Returns None when no pyproject.toml exists."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
result = find_pyproject()
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_load_pyproject_returns_tool_config(self, tmp_path, monkeypatch):
|
||||||
|
"""load_pyproject returns the [tool.fastapi-toolsets] section."""
|
||||||
pyproject = tmp_path / "pyproject.toml"
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
pyproject.write_text(
|
pyproject.write_text(
|
||||||
'[tool.fastapi-toolsets]\nfixtures = "app.fixtures:registry"\n'
|
'[tool.fastapi-toolsets]\nfixtures = "app.fixtures:registry"\n'
|
||||||
)
|
)
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
config = load_config()
|
result = load_pyproject()
|
||||||
assert config.fixtures == "app.fixtures:registry"
|
assert result == {"fixtures": "app.fixtures:registry"}
|
||||||
assert config.db_context is None
|
|
||||||
|
|
||||||
def test_load_with_full_config(self, tmp_path, monkeypatch):
|
def test_load_pyproject_empty_when_no_file(self, tmp_path, monkeypatch):
|
||||||
"""Loads full config from pyproject.toml."""
|
"""Returns empty dict when no pyproject.toml exists."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
result = load_pyproject()
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
def test_load_pyproject_empty_when_no_tool_section(self, tmp_path, monkeypatch):
|
||||||
|
"""Returns empty dict when no [tool.fastapi-toolsets] section."""
|
||||||
pyproject = tmp_path / "pyproject.toml"
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
pyproject.write_text(
|
pyproject.write_text("[project]\nname = 'test'\n")
|
||||||
"[tool.fastapi-toolsets]\n"
|
|
||||||
'fixtures = "app.fixtures:registry"\n'
|
|
||||||
'db_context = "app.db:get_session"\n'
|
|
||||||
)
|
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
config = load_config()
|
result = load_pyproject()
|
||||||
assert config.fixtures == "app.fixtures:registry"
|
assert result == {}
|
||||||
assert config.db_context == "app.db:get_session"
|
|
||||||
|
|
||||||
def test_load_with_invalid_toml(self, tmp_path, monkeypatch):
|
def test_load_pyproject_invalid_toml(self, tmp_path, monkeypatch):
|
||||||
"""Returns empty config when pyproject.toml is invalid."""
|
"""Returns empty dict when pyproject.toml is invalid."""
|
||||||
pyproject = tmp_path / "pyproject.toml"
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
pyproject.write_text("invalid toml {{{")
|
pyproject.write_text("invalid toml {{{")
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
config = load_config()
|
result = load_pyproject()
|
||||||
assert config.fixtures is None
|
assert result == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestImportFromString:
|
||||||
|
"""Tests for import_from_string function."""
|
||||||
|
|
||||||
|
def test_import_valid_path(self):
|
||||||
|
"""Import valid module:attribute path."""
|
||||||
|
result = import_from_string("fastapi_toolsets.fixtures:FixtureRegistry")
|
||||||
|
assert result is FixtureRegistry
|
||||||
|
|
||||||
|
def test_import_without_colon_raises_error(self):
|
||||||
|
"""Import path without colon raises error."""
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
import_from_string("fastapi_toolsets.fixtures.FixtureRegistry")
|
||||||
|
assert "Expected format: 'module:attribute'" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_import_nonexistent_module_raises_error(self):
|
||||||
|
"""Import nonexistent module raises error."""
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
import_from_string("nonexistent.module:something")
|
||||||
|
assert "Cannot import module" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_import_nonexistent_attribute_raises_error(self):
|
||||||
|
"""Import nonexistent attribute raises error."""
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
import_from_string("fastapi_toolsets.fixtures:NonexistentClass")
|
||||||
|
assert "has no attribute" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetConfigValue:
|
||||||
|
"""Tests for get_config_value function."""
|
||||||
|
|
||||||
|
def test_get_existing_value(self, tmp_path, monkeypatch):
|
||||||
|
"""Returns value when key exists."""
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text('[tool.fastapi-toolsets]\nfixtures = "app:registry"\n')
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
|
result = get_config_value("fixtures")
|
||||||
|
assert result == "app:registry"
|
||||||
|
|
||||||
|
def test_get_missing_value_returns_none(self, tmp_path, monkeypatch):
|
||||||
|
"""Returns None when key is missing and not required."""
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text("[tool.fastapi-toolsets]\n")
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
|
result = get_config_value("fixtures")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_get_missing_value_required_raises_error(self, tmp_path, monkeypatch):
|
||||||
|
"""Raises error when key is missing and required."""
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text("[tool.fastapi-toolsets]\n")
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
get_config_value("fixtures", required=True)
|
||||||
|
assert "No 'fixtures' configured" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetFixturesRegistry:
|
||||||
|
"""Tests for get_fixtures_registry function."""
|
||||||
|
|
||||||
|
def test_raises_when_not_configured(self, tmp_path, monkeypatch):
|
||||||
|
"""Raises error when fixtures not configured."""
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text("[tool.fastapi-toolsets]\n")
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
get_fixtures_registry()
|
||||||
|
assert "No 'fixtures' configured" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_raises_when_not_registry_instance(self, tmp_path, monkeypatch):
|
||||||
|
"""Raises error when imported object is not a FixtureRegistry."""
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text(
|
||||||
|
'[tool.fastapi-toolsets]\nfixtures = "my_fixtures:registry"\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
fixtures_file = tmp_path / "my_fixtures.py"
|
||||||
|
fixtures_file.write_text("registry = 'not a registry'\n")
|
||||||
|
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
if str(tmp_path) not in sys.path:
|
||||||
|
sys.path.insert(0, str(tmp_path))
|
||||||
|
|
||||||
|
try:
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
get_fixtures_registry()
|
||||||
|
assert "must be a FixtureRegistry instance" in str(exc_info.value)
|
||||||
|
finally:
|
||||||
|
if str(tmp_path) in sys.path:
|
||||||
|
sys.path.remove(str(tmp_path))
|
||||||
|
if "my_fixtures" in sys.modules:
|
||||||
|
del sys.modules["my_fixtures"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetDbContext:
|
||||||
|
"""Tests for get_db_context function."""
|
||||||
|
|
||||||
|
def test_raises_when_not_configured(self, tmp_path, monkeypatch):
|
||||||
|
"""Raises error when db_context not configured."""
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text("[tool.fastapi-toolsets]\n")
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
get_db_context()
|
||||||
|
assert "No 'db_context' configured" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCustomCli:
|
||||||
|
"""Tests for get_custom_cli function."""
|
||||||
|
|
||||||
|
def test_returns_none_when_not_configured(self, tmp_path, monkeypatch):
|
||||||
|
"""Returns None when custom_cli not configured."""
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text("[tool.fastapi-toolsets]\n")
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
|
result = get_custom_cli()
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_raises_when_not_typer_instance(self, tmp_path, monkeypatch):
|
||||||
|
"""Raises error when imported object is not a Typer instance."""
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text('[tool.fastapi-toolsets]\ncustom_cli = "my_cli:cli"\n')
|
||||||
|
|
||||||
|
cli_file = tmp_path / "my_cli.py"
|
||||||
|
cli_file.write_text("cli = 'not a typer'\n")
|
||||||
|
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
if str(tmp_path) not in sys.path:
|
||||||
|
sys.path.insert(0, str(tmp_path))
|
||||||
|
|
||||||
|
try:
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
get_custom_cli()
|
||||||
|
assert "must be a Typer instance" in str(exc_info.value)
|
||||||
|
finally:
|
||||||
|
if str(tmp_path) in sys.path:
|
||||||
|
sys.path.remove(str(tmp_path))
|
||||||
|
if "my_cli" in sys.modules:
|
||||||
|
del sys.modules["my_cli"]
|
||||||
|
|
||||||
|
|
||||||
class TestCliApp:
|
class TestCliApp:
|
||||||
"""Tests for CLI application."""
|
"""Tests for CLI application."""
|
||||||
|
|
||||||
def test_cli_import(self):
|
|
||||||
"""CLI can be imported."""
|
|
||||||
from fastapi_toolsets.cli import cli
|
|
||||||
|
|
||||||
assert cli is not None
|
|
||||||
|
|
||||||
def test_cli_help(self, tmp_path, monkeypatch):
|
def test_cli_help(self, tmp_path, monkeypatch):
|
||||||
"""CLI shows help without fixtures."""
|
"""CLI shows help without fixtures."""
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
@@ -202,9 +298,8 @@ class TestFixturesCli:
|
|||||||
# Reload the CLI module to pick up new config
|
# Reload the CLI module to pick up new config
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
from fastapi_toolsets.cli import app, config
|
from fastapi_toolsets.cli import app
|
||||||
|
|
||||||
importlib.reload(config)
|
|
||||||
importlib.reload(app)
|
importlib.reload(app)
|
||||||
|
|
||||||
yield tmp_path, app.cli
|
yield tmp_path, app.cli
|
||||||
@@ -250,8 +345,7 @@ class TestFixturesCli:
|
|||||||
cli, ["fixtures", "load", "base", "--strategy", "invalid"]
|
cli, ["fixtures", "load", "base", "--strategy", "invalid"]
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.exit_code == 1
|
assert result.exit_code != 0
|
||||||
assert "Invalid strategy" in result.output
|
|
||||||
|
|
||||||
|
|
||||||
class TestCliWithoutFixturesConfig:
|
class TestCliWithoutFixturesConfig:
|
||||||
@@ -268,9 +362,8 @@ class TestCliWithoutFixturesConfig:
|
|||||||
# Reload the CLI module
|
# Reload the CLI module
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
from fastapi_toolsets.cli import app, config
|
from fastapi_toolsets.cli import app
|
||||||
|
|
||||||
importlib.reload(config)
|
|
||||||
importlib.reload(app)
|
importlib.reload(app)
|
||||||
|
|
||||||
result = runner.invoke(app.cli, ["--help"])
|
result = runner.invoke(app.cli, ["--help"])
|
||||||
@@ -279,6 +372,134 @@ class TestCliWithoutFixturesConfig:
|
|||||||
assert "fixtures" not in result.output
|
assert "fixtures" not in result.output
|
||||||
|
|
||||||
|
|
||||||
|
class TestCustomCliConfig:
|
||||||
|
"""Tests for custom CLI configuration."""
|
||||||
|
|
||||||
|
def test_cli_with_custom_cli(self, tmp_path, monkeypatch):
|
||||||
|
"""CLI uses custom Typer instance when configured."""
|
||||||
|
import typer
|
||||||
|
|
||||||
|
# Create pyproject.toml with custom_cli config
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text('[tool.fastapi-toolsets]\ncustom_cli = "my_cli:cli"\n')
|
||||||
|
|
||||||
|
# Create custom CLI module with its own Typer and commands
|
||||||
|
cli_file = tmp_path / "my_cli.py"
|
||||||
|
cli_file.write_text(
|
||||||
|
"import typer\n"
|
||||||
|
"\n"
|
||||||
|
"cli = typer.Typer(name='my-app', help='My custom CLI')\n"
|
||||||
|
"\n"
|
||||||
|
"@cli.command()\n"
|
||||||
|
"def hello():\n"
|
||||||
|
' print("Hello from custom CLI!")\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
|
# Add tmp_path to sys.path for imports
|
||||||
|
if str(tmp_path) not in sys.path:
|
||||||
|
sys.path.insert(0, str(tmp_path))
|
||||||
|
|
||||||
|
# Remove my_cli from sys.modules if it was previously loaded
|
||||||
|
if "my_cli" in sys.modules:
|
||||||
|
del sys.modules["my_cli"]
|
||||||
|
|
||||||
|
# Reload the CLI module to pick up new config
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
from fastapi_toolsets.cli import app
|
||||||
|
|
||||||
|
importlib.reload(app)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Verify custom CLI is used
|
||||||
|
assert isinstance(app.cli, typer.Typer)
|
||||||
|
|
||||||
|
result = runner.invoke(app.cli, ["--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "My custom CLI" in result.output
|
||||||
|
assert "hello" in result.output
|
||||||
|
|
||||||
|
result = runner.invoke(app.cli, ["hello"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Hello from custom CLI!" in result.output
|
||||||
|
finally:
|
||||||
|
if str(tmp_path) in sys.path:
|
||||||
|
sys.path.remove(str(tmp_path))
|
||||||
|
if "my_cli" in sys.modules:
|
||||||
|
del sys.modules["my_cli"]
|
||||||
|
|
||||||
|
def test_custom_cli_with_fixtures(self, tmp_path, monkeypatch):
|
||||||
|
"""Custom CLI gets fixtures command added when configured."""
|
||||||
|
# Create pyproject.toml with both custom_cli and fixtures
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text(
|
||||||
|
"[tool.fastapi-toolsets]\n"
|
||||||
|
'custom_cli = "my_cli:cli"\n'
|
||||||
|
'fixtures = "fixtures:registry"\n'
|
||||||
|
'db_context = "db:get_session"\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create custom CLI module
|
||||||
|
cli_file = tmp_path / "my_cli.py"
|
||||||
|
cli_file.write_text(
|
||||||
|
"import typer\n"
|
||||||
|
"\n"
|
||||||
|
"cli = typer.Typer(name='my-app', help='My custom CLI')\n"
|
||||||
|
"\n"
|
||||||
|
"@cli.command()\n"
|
||||||
|
"def hello():\n"
|
||||||
|
' print("Hello!")\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create fixtures module
|
||||||
|
fixtures_file = tmp_path / "fixtures.py"
|
||||||
|
fixtures_file.write_text(
|
||||||
|
"from fastapi_toolsets.fixtures import FixtureRegistry\n"
|
||||||
|
"\n"
|
||||||
|
"registry = FixtureRegistry()\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create db module
|
||||||
|
db_file = tmp_path / "db.py"
|
||||||
|
db_file.write_text(
|
||||||
|
"from contextlib import asynccontextmanager\n"
|
||||||
|
"\n"
|
||||||
|
"@asynccontextmanager\n"
|
||||||
|
"async def get_session():\n"
|
||||||
|
" yield None\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
|
if str(tmp_path) not in sys.path:
|
||||||
|
sys.path.insert(0, str(tmp_path))
|
||||||
|
|
||||||
|
for mod in ["my_cli", "fixtures", "db"]:
|
||||||
|
if mod in sys.modules:
|
||||||
|
del sys.modules[mod]
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
from fastapi_toolsets.cli import app
|
||||||
|
|
||||||
|
importlib.reload(app)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = runner.invoke(app.cli, ["--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
# Should have both custom command and fixtures
|
||||||
|
assert "hello" in result.output
|
||||||
|
assert "fixtures" in result.output
|
||||||
|
finally:
|
||||||
|
if str(tmp_path) in sys.path:
|
||||||
|
sys.path.remove(str(tmp_path))
|
||||||
|
for mod in ["my_cli", "fixtures", "db"]:
|
||||||
|
if mod in sys.modules:
|
||||||
|
del sys.modules[mod]
|
||||||
|
|
||||||
|
|
||||||
class TestAsyncCommand:
|
class TestAsyncCommand:
|
||||||
"""Tests for async_command decorator."""
|
"""Tests for async_command decorator."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user