From e0bc93096d0bbc034a9201c1d64b1dcad542ba24 Mon Sep 17 00:00:00 2001 From: d3vyce <44915747+d3vyce@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:19:07 +0100 Subject: [PATCH] feat: allow custom CLI (#28) --- README.md | 2 +- src/fastapi_toolsets/cli/__init__.py | 3 +- src/fastapi_toolsets/cli/app.py | 23 +- src/fastapi_toolsets/cli/commands/fixtures.py | 51 +-- src/fastapi_toolsets/cli/config.py | 133 +++--- src/fastapi_toolsets/cli/pyproject.py | 43 ++ tests/test_cli.py | 433 +++++++++++++----- 7 files changed, 476 insertions(+), 212 deletions(-) create mode 100644 src/fastapi_toolsets/cli/pyproject.py diff --git a/README.md b/README.md index 854fe4f..b913042 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ uv add fastapi-toolsets - **CRUD**: Generic async CRUD operations with `CrudFactory` - **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 - **Exception Handling**: Structured error responses with automatic OpenAPI documentation diff --git a/src/fastapi_toolsets/cli/__init__.py b/src/fastapi_toolsets/cli/__init__.py index 21b7a5b..53037a0 100644 --- a/src/fastapi_toolsets/cli/__init__.py +++ b/src/fastapi_toolsets/cli/__init__.py @@ -1,6 +1,5 @@ """CLI for FastAPI projects.""" -from .app import cli from .utils import async_command -__all__ = ["async_command", "cli"] +__all__ = ["async_command"] diff --git a/src/fastapi_toolsets/cli/app.py b/src/fastapi_toolsets/cli/app.py index 8234268..5e3dc8e 100644 --- a/src/fastapi_toolsets/cli/app.py +++ b/src/fastapi_toolsets/cli/app.py @@ -2,17 +2,23 @@ import typer -from .config import load_config +from .config import get_custom_cli +from .pyproject import load_pyproject -cli = typer.Typer( - name="manager", - help="CLI utilities for FastAPI projects.", - no_args_is_help=True, -) +# Use custom CLI if configured, otherwise create default one +_custom_cli = get_custom_cli() -_config = load_config() +if _custom_cli is not None: + cli = _custom_cli +else: + cli = typer.Typer( + name="manager", + help="CLI utilities for FastAPI projects.", + no_args_is_help=True, + ) -if _config.fixtures: +_config = load_pyproject() +if _config.get("fixtures") and _config.get("db_context"): from .commands.fixtures import fixture_cli cli.add_typer(fixture_cli, name="fixtures") @@ -22,4 +28,3 @@ if _config.fixtures: def main(ctx: typer.Context) -> None: """FastAPI utilities CLI.""" ctx.ensure_object(dict) - ctx.obj["config"] = _config diff --git a/src/fastapi_toolsets/cli/commands/fixtures.py b/src/fastapi_toolsets/cli/commands/fixtures.py index 17fa198..a782549 100644 --- a/src/fastapi_toolsets/cli/commands/fixtures.py +++ b/src/fastapi_toolsets/cli/commands/fixtures.py @@ -7,7 +7,7 @@ from rich.console import Console from rich.table import Table 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 fixture_cli = typer.Typer( @@ -18,27 +18,21 @@ fixture_cli = typer.Typer( console = Console() -def _get_config(ctx: typer.Context) -> CliConfig: - """Get CLI config from context.""" - return ctx.obj["config"] - - @fixture_cli.command("list") def list_fixtures( ctx: typer.Context, context: Annotated[ - str | None, + Context | None, typer.Option( "--context", "-c", - help="Filter by context (base, production, development, testing).", + help="Filter by context.", ), ] = None, ) -> None: """List all registered fixtures.""" - config = _get_config(ctx) - registry = config.get_fixtures_registry() - fixtures = registry.get_by_context(context) if context else registry.get_all() + registry = get_fixtures_registry() + fixtures = registry.get_by_context(context.value) if context else registry.get_all() if not fixtures: print("No fixtures found.") @@ -60,17 +54,13 @@ def list_fixtures( async def load( ctx: typer.Context, contexts: Annotated[ - list[str] | None, - typer.Argument( - help="Contexts to load (base, production, development, testing)." - ), + list[Context] | None, + typer.Argument(help="Contexts to load."), ] = None, strategy: Annotated[ - str, - typer.Option( - "--strategy", "-s", help="Load strategy: merge, insert, skip_existing." - ), - ] = "merge", + LoadStrategy, + typer.Option("--strategy", "-s", help="Load strategy."), + ] = LoadStrategy.MERGE, dry_run: Annotated[ bool, typer.Option( @@ -79,19 +69,10 @@ async def load( ] = False, ) -> None: """Load fixtures into the database.""" - config = _get_config(ctx) - registry = config.get_fixtures_registry() - get_db_context = config.get_db_context() + registry = get_fixtures_registry() + db_context = get_db_context() - context_list = 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) + context_list = [c.value for c in contexts] if contexts else [Context.BASE] ordered = registry.resolve_context_dependencies(*context_list) @@ -99,7 +80,7 @@ async def load( print("No fixtures to load for the specified context(s).") return - print(f"\nFixtures to load ({load_strategy.value} strategy):") + print(f"\nFixtures to load ({strategy.value} strategy):") for name in ordered: fixture = registry.get(name) instances = list(fixture.func()) @@ -110,9 +91,9 @@ async def load( print("\n[Dry run - no changes made]") return - async with get_db_context() as session: + async with db_context() as session: 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()) diff --git a/src/fastapi_toolsets/cli/config.py b/src/fastapi_toolsets/cli/config.py index 0c4ad34..600b3f5 100644 --- a/src/fastapi_toolsets/cli/config.py +++ b/src/fastapi_toolsets/cli/config.py @@ -1,52 +1,28 @@ -"""CLI configuration.""" +"""CLI configuration and dynamic imports.""" 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) +from .pyproject import find_pyproject, load_pyproject -def _import_from_string(import_path: str): - """Import an object from a string path like 'module.submodule:attribute'.""" +def _ensure_project_in_path(): + """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: raise typer.BadParameter( 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) - # Add cwd to sys.path for local imports - cwd = str(Path.cwd()) - if cwd not in sys.path: - sys.path.insert(0, cwd) + _ensure_project_in_path() try: module = importlib.import_module(module_path) @@ -72,21 +45,63 @@ def _import_from_string(import_path: str): return getattr(module, attr_name) -def load_config() -> CliConfig: - """Load CLI configuration from pyproject.toml.""" - pyproject_path = Path.cwd() / "pyproject.toml" +def get_config_value(key: str, required: bool = False): + """Get a configuration value from pyproject.toml. - if not pyproject_path.exists(): - return CliConfig() + Args: + key: The configuration key in [tool.fastapi-toolsets]. + required: If True, raises an error when the key is missing. - try: - with open(pyproject_path, "rb") as f: - data = tomllib.load(f) + Returns: + The configuration value, or None if not found and not required. - tool_config = data.get("tool", {}).get("fastapi-toolsets", {}) - return CliConfig( - fixtures=tool_config.get("fixtures"), - db_context=tool_config.get("db_context"), + Raises: + typer.BadParameter: If required=True and the key is missing. + """ + 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 diff --git a/src/fastapi_toolsets/cli/pyproject.py b/src/fastapi_toolsets/cli/pyproject.py new file mode 100644 index 0000000..859658a --- /dev/null +++ b/src/fastapi_toolsets/cli/pyproject.py @@ -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 {} diff --git a/tests/test_cli.py b/tests/test_cli.py index ee2be94..6c172f5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,138 +5,234 @@ import sys import pytest 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.fixtures import FixtureRegistry runner = CliRunner() -class TestCliConfig: - """Tests for CliConfig dataclass.""" +class TestPyproject: + """Tests for pyproject.toml discovery and loading.""" - def test_default_values(self): - """Config has None defaults.""" - 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.""" + def test_find_pyproject_in_current_dir(self, tmp_path, monkeypatch): + """Finds pyproject.toml in current directory.""" pyproject = tmp_path / "pyproject.toml" pyproject.write_text("[project]\nname = 'test'\n") monkeypatch.chdir(tmp_path) - config = load_config() - assert config.fixtures is None - assert config.db_context is None + result = find_pyproject() + assert result == pyproject - def test_load_with_fixtures_config(self, tmp_path, monkeypatch): - """Loads fixtures config from pyproject.toml.""" + def test_find_pyproject_in_parent_dir(self, tmp_path, monkeypatch): + """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.write_text( '[tool.fastapi-toolsets]\nfixtures = "app.fixtures:registry"\n' ) monkeypatch.chdir(tmp_path) - config = load_config() - assert config.fixtures == "app.fixtures:registry" - assert config.db_context is None + result = load_pyproject() + assert result == {"fixtures": "app.fixtures:registry"} - def test_load_with_full_config(self, tmp_path, monkeypatch): - """Loads full config from pyproject.toml.""" + def test_load_pyproject_empty_when_no_file(self, tmp_path, monkeypatch): + """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.write_text( - "[tool.fastapi-toolsets]\n" - 'fixtures = "app.fixtures:registry"\n' - 'db_context = "app.db:get_session"\n' - ) + pyproject.write_text("[project]\nname = 'test'\n") monkeypatch.chdir(tmp_path) - config = load_config() - assert config.fixtures == "app.fixtures:registry" - assert config.db_context == "app.db:get_session" + result = load_pyproject() + assert result == {} - def test_load_with_invalid_toml(self, tmp_path, monkeypatch): - """Returns empty config when pyproject.toml is invalid.""" + def test_load_pyproject_invalid_toml(self, tmp_path, monkeypatch): + """Returns empty dict when pyproject.toml is invalid.""" pyproject = tmp_path / "pyproject.toml" pyproject.write_text("invalid toml {{{") monkeypatch.chdir(tmp_path) - config = load_config() - assert config.fixtures is None + result = load_pyproject() + 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: """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): """CLI shows help without fixtures.""" monkeypatch.chdir(tmp_path) @@ -202,9 +298,8 @@ class TestFixturesCli: # Reload the CLI module to pick up new config import importlib - from fastapi_toolsets.cli import app, config + from fastapi_toolsets.cli import app - importlib.reload(config) importlib.reload(app) yield tmp_path, app.cli @@ -250,8 +345,7 @@ class TestFixturesCli: cli, ["fixtures", "load", "base", "--strategy", "invalid"] ) - assert result.exit_code == 1 - assert "Invalid strategy" in result.output + assert result.exit_code != 0 class TestCliWithoutFixturesConfig: @@ -268,9 +362,8 @@ class TestCliWithoutFixturesConfig: # Reload the CLI module import importlib - from fastapi_toolsets.cli import app, config + from fastapi_toolsets.cli import app - importlib.reload(config) importlib.reload(app) result = runner.invoke(app.cli, ["--help"]) @@ -279,6 +372,134 @@ class TestCliWithoutFixturesConfig: 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: """Tests for async_command decorator."""