From 32ed36e102049296fc12f862cdf197abce69d652 Mon Sep 17 00:00:00 2001 From: d3vyce <44915747+d3vyce@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:08:18 +0100 Subject: [PATCH] feat: add proper optional-dependencies for each modules (#75) --- README.md | 24 ++- pyproject.toml | 24 ++- src/fastapi_toolsets/_imports.py | 9 + src/fastapi_toolsets/cli/app.py | 7 +- src/fastapi_toolsets/metrics/__init__.py | 13 +- src/fastapi_toolsets/pytest/__init__.py | 27 ++- tests/test_imports.py | 229 +++++++++++++++++++++++ uv.lock | 42 ++++- 8 files changed, 346 insertions(+), 29 deletions(-) create mode 100644 src/fastapi_toolsets/_imports.py create mode 100644 tests/test_imports.py diff --git a/README.md b/README.md index d99bcb0..480d0fd 100644 --- a/README.md +++ b/README.md @@ -20,20 +20,42 @@ FastAPI Toolsets provides production-ready utilities for FastAPI applications bu ## Installation +The base package includes the core modules (CRUD, database, schemas, exceptions, fixtures, dependencies, logging): + ```bash uv add fastapi-toolsets ``` +Install only the extras you need: + +```bash +uv add "fastapi-toolsets[cli]" # CLI (typer) +uv add "fastapi-toolsets[metrics]" # Prometheus metrics (prometheus_client) +uv add "fastapi-toolsets[pytest]" # Pytest helpers (httpx, pytest-xdist) +``` + +Or install everything: + +```bash +uv add "fastapi-toolsets[all]" +``` + ## Features +### Core + - **CRUD**: Generic async CRUD operations with `CrudFactory`, built-in search with relationship traversal - **Database**: Session management, transaction helpers, table locking, and polling-based row change detection - **Dependencies**: FastAPI dependency factories (`PathDependency`, `BodyDependency`) for automatic DB lookups from path or body parameters - **Fixtures**: Fixture system with dependency management, context support, and pytest integration -- **CLI**: Django-like command-line interface with fixture management and custom commands support - **Standardized API Responses**: Consistent response format with `Response`, `PaginatedResponse`, and `PydanticBase` - **Exception Handling**: Structured error responses with automatic OpenAPI documentation - **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger` + +### Optional + +- **CLI**: Django-like command-line interface with fixture management and custom commands support +- **Metrics**: Prometheus metrics endpoint with provider/collector registry - **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities ## License diff --git a/pyproject.toml b/pyproject.toml index 1326bbb..79bbeb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,12 +31,10 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "fastapi>=0.100.0", - "sqlalchemy[asyncio]>=2.0", "asyncpg>=0.29.0", + "fastapi>=0.100.0", "pydantic>=2.0", - "typer>=0.9.0", - "httpx>=0.25.0", + "sqlalchemy[asyncio]>=2.0", ] [project.urls] @@ -46,18 +44,28 @@ Repository = "https://github.com/d3vyce/fastapi-toolsets" Issues = "https://github.com/d3vyce/fastapi-toolsets/issues" [project.optional-dependencies] +cli = [ + "typer>=0.9.0", +] metrics = [ "prometheus_client>=0.20.0", ] -test = [ - "pytest>=8.0.0", - "pytest-anyio>=0.0.0", +pytest = [ + "httpx>=0.25.0", "pytest-xdist>=3.0.0", + "pytest>=8.0.0", +] +all = [ + "fastapi-toolsets[cli,metrics,pytest]", +] +test = [ "coverage>=7.0.0", + "fastapi-toolsets[pytest]", + "pytest-anyio>=0.0.0", "pytest-cov>=4.0.0", ] dev = [ - "fastapi-toolsets[metrics,test]", + "fastapi-toolsets[all,test]", "ruff>=0.1.0", "ty>=0.0.1a0", ] diff --git a/src/fastapi_toolsets/_imports.py b/src/fastapi_toolsets/_imports.py new file mode 100644 index 0000000..4d81e55 --- /dev/null +++ b/src/fastapi_toolsets/_imports.py @@ -0,0 +1,9 @@ +"""Optional dependency helpers.""" + + +def require_extra(package: str, extra: str) -> None: + """Raise *ImportError* with an actionable install instruction.""" + raise ImportError( + f"'{package}' is required to use this module. " + f"Install it with: pip install fastapi-toolsets[{extra}]" + ) diff --git a/src/fastapi_toolsets/cli/app.py b/src/fastapi_toolsets/cli/app.py index d84dea8..80a16db 100644 --- a/src/fastapi_toolsets/cli/app.py +++ b/src/fastapi_toolsets/cli/app.py @@ -1,6 +1,11 @@ """Main CLI application.""" -import typer +try: + import typer +except ImportError: + from .._imports import require_extra + + require_extra(package="typer", extra="cli") from ..logger import configure_logging from .config import get_custom_cli diff --git a/src/fastapi_toolsets/metrics/__init__.py b/src/fastapi_toolsets/metrics/__init__.py index c96ecf6..c77edca 100644 --- a/src/fastapi_toolsets/metrics/__init__.py +++ b/src/fastapi_toolsets/metrics/__init__.py @@ -1,8 +1,19 @@ """Prometheus metrics integration for FastAPI applications.""" -from .handler import init_metrics +from typing import Any + from .registry import Metric, MetricsRegistry +try: + from .handler import init_metrics +except ImportError: + + def init_metrics(*_args: Any, **_kwargs: Any) -> None: + from .._imports import require_extra + + require_extra(package="prometheus_client", extra="metrics") + + __all__ = [ "Metric", "MetricsRegistry", diff --git a/src/fastapi_toolsets/pytest/__init__.py b/src/fastapi_toolsets/pytest/__init__.py index 9f67307..7eb14ac 100644 --- a/src/fastapi_toolsets/pytest/__init__.py +++ b/src/fastapi_toolsets/pytest/__init__.py @@ -1,13 +1,24 @@ """Pytest helpers for FastAPI testing: sessions, clients, and fixtures.""" -from .plugin import register_fixtures -from .utils import ( - cleanup_tables, - create_async_client, - create_db_session, - create_worker_database, - worker_database_url, -) +try: + from .plugin import register_fixtures +except ImportError: + from .._imports import require_extra + + require_extra(package="pytest", extra="pytest") + +try: + from .utils import ( + cleanup_tables, + create_async_client, + create_db_session, + create_worker_database, + worker_database_url, + ) +except ImportError: + from .._imports import require_extra + + require_extra(package="httpx", extra="pytest") __all__ = [ "cleanup_tables", diff --git a/tests/test_imports.py b/tests/test_imports.py new file mode 100644 index 0000000..7245208 --- /dev/null +++ b/tests/test_imports.py @@ -0,0 +1,229 @@ +"""Tests for optional dependency import guards.""" + +import importlib +import sys +from unittest.mock import patch + +import pytest + +from fastapi_toolsets._imports import require_extra + + +class TestRequireExtra: + """Tests for the require_extra helper.""" + + def test_raises_import_error(self): + """require_extra raises ImportError.""" + with pytest.raises(ImportError): + require_extra(package="some_pkg", extra="some_extra") + + def test_error_message_contains_package_name(self): + """Error message mentions the missing package.""" + with pytest.raises(ImportError, match="'prometheus_client'"): + require_extra(package="prometheus_client", extra="metrics") + + def test_error_message_contains_install_instruction(self): + """Error message contains the pip install command.""" + with pytest.raises( + ImportError, match=r"pip install fastapi-toolsets\[metrics\]" + ): + require_extra(package="prometheus_client", extra="metrics") + + +def _reload_without_package(module_path: str, blocked_packages: list[str]): + """Reload a module while blocking specific package imports. + + Removes the target module and its parents from sys.modules so they + get re-imported, and patches builtins.__import__ to raise ImportError + for *blocked_packages*. + """ + # Remove cached modules so they get re-imported + to_remove = [ + key + for key in sys.modules + if key == module_path or key.startswith(module_path + ".") + ] + saved = {} + for key in to_remove: + saved[key] = sys.modules.pop(key) + + # Also remove parent package to force re-execution of __init__.py + parts = module_path.rsplit(".", 1) + if len(parts) == 2: + parent = parts[0] + parent_keys = [ + key for key in sys.modules if key == parent or key.startswith(parent + ".") + ] + for key in parent_keys: + if key not in saved: + saved[key] = sys.modules.pop(key) + + original_import = ( + __builtins__.__import__ if hasattr(__builtins__, "__import__") else __import__ + ) + + def blocking_import(name, *args, **kwargs): + for blocked in blocked_packages: + if name == blocked or name.startswith(blocked + "."): + raise ImportError(f"Mocked: No module named '{name}'") + return original_import(name, *args, **kwargs) + + return saved, blocking_import + + +class TestMetricsImportGuard: + """Tests for metrics module import guard when prometheus_client is missing.""" + + def test_registry_imports_without_prometheus(self): + """Metric and MetricsRegistry are importable without prometheus_client.""" + saved, blocking_import = _reload_without_package( + "fastapi_toolsets.metrics", ["prometheus_client"] + ) + try: + with patch("builtins.__import__", side_effect=blocking_import): + mod = importlib.import_module("fastapi_toolsets.metrics") + # Registry types should be available (they're stdlib-only) + assert hasattr(mod, "Metric") + assert hasattr(mod, "MetricsRegistry") + finally: + # Restore original modules + for key in list(sys.modules): + if key.startswith("fastapi_toolsets.metrics"): + sys.modules.pop(key, None) + sys.modules.update(saved) + + def test_init_metrics_stub_raises_without_prometheus(self): + """init_metrics raises ImportError when prometheus_client is missing.""" + saved, blocking_import = _reload_without_package( + "fastapi_toolsets.metrics", ["prometheus_client"] + ) + try: + with patch("builtins.__import__", side_effect=blocking_import): + mod = importlib.import_module("fastapi_toolsets.metrics") + with pytest.raises(ImportError, match="prometheus_client"): + mod.init_metrics(None, None) # type: ignore[arg-type] + finally: + for key in list(sys.modules): + if key.startswith("fastapi_toolsets.metrics"): + sys.modules.pop(key, None) + sys.modules.update(saved) + + def test_init_metrics_works_with_prometheus(self): + """init_metrics is the real function when prometheus_client is available.""" + from fastapi_toolsets.metrics import init_metrics + + # Should be the real function, not a stub + assert init_metrics.__module__ == "fastapi_toolsets.metrics.handler" + + +class TestPytestImportGuard: + """Tests for pytest module import guard when dependencies are missing.""" + + def test_import_raises_without_pytest_package(self): + """Importing fastapi_toolsets.pytest raises when pytest is missing.""" + saved, blocking_import = _reload_without_package( + "fastapi_toolsets.pytest", ["pytest"] + ) + try: + with patch("builtins.__import__", side_effect=blocking_import): + with pytest.raises(ImportError, match="pytest"): + importlib.import_module("fastapi_toolsets.pytest") + finally: + for key in list(sys.modules): + if key.startswith("fastapi_toolsets.pytest"): + sys.modules.pop(key, None) + sys.modules.update(saved) + + def test_import_raises_without_httpx(self): + """Importing fastapi_toolsets.pytest raises when httpx is missing.""" + saved, blocking_import = _reload_without_package( + "fastapi_toolsets.pytest", ["httpx"] + ) + try: + with patch("builtins.__import__", side_effect=blocking_import): + with pytest.raises(ImportError, match="httpx"): + importlib.import_module("fastapi_toolsets.pytest") + finally: + for key in list(sys.modules): + if key.startswith("fastapi_toolsets.pytest"): + sys.modules.pop(key, None) + sys.modules.update(saved) + + def test_all_exports_available_with_deps(self): + """All expected exports are available when deps are installed.""" + from fastapi_toolsets.pytest import ( + cleanup_tables, + create_async_client, + create_db_session, + create_worker_database, + register_fixtures, + worker_database_url, + ) + + assert callable(register_fixtures) + assert callable(create_async_client) + assert callable(create_db_session) + assert callable(create_worker_database) + assert callable(worker_database_url) + assert callable(cleanup_tables) + + +class TestCliImportGuard: + """Tests for CLI module import guard when typer is missing.""" + + def test_import_raises_without_typer(self): + """Importing cli.app raises when typer is missing.""" + saved, blocking_import = _reload_without_package( + "fastapi_toolsets.cli.app", ["typer"] + ) + # Also remove cli.config since it imports typer too + config_keys = [ + k for k in sys.modules if k.startswith("fastapi_toolsets.cli.config") + ] + for key in config_keys: + if key not in saved: + saved[key] = sys.modules.pop(key) + + try: + with patch("builtins.__import__", side_effect=blocking_import): + with pytest.raises(ImportError, match="typer"): + importlib.import_module("fastapi_toolsets.cli.app") + finally: + for key in list(sys.modules): + if key.startswith("fastapi_toolsets.cli.app") or key.startswith( + "fastapi_toolsets.cli.config" + ): + sys.modules.pop(key, None) + sys.modules.update(saved) + + def test_error_message_suggests_cli_extra(self): + """Error message suggests installing the cli extra.""" + saved, blocking_import = _reload_without_package( + "fastapi_toolsets.cli.app", ["typer"] + ) + config_keys = [ + k for k in sys.modules if k.startswith("fastapi_toolsets.cli.config") + ] + for key in config_keys: + if key not in saved: + saved[key] = sys.modules.pop(key) + + try: + with patch("builtins.__import__", side_effect=blocking_import): + with pytest.raises( + ImportError, match=r"pip install fastapi-toolsets\[cli\]" + ): + importlib.import_module("fastapi_toolsets.cli.app") + finally: + for key in list(sys.modules): + if key.startswith("fastapi_toolsets.cli.app") or key.startswith( + "fastapi_toolsets.cli.config" + ): + sys.modules.pop(key, None) + sys.modules.update(saved) + + def test_async_command_imports_without_typer(self): + """async_command is importable without typer (stdlib only).""" + from fastapi_toolsets.cli import async_command + + assert callable(async_command) diff --git a/uv.lock b/uv.lock index 42d93fd..5a4437f 100644 --- a/uv.lock +++ b/uv.lock @@ -245,17 +245,30 @@ name = "fastapi-toolsets" version = "0.10.0" source = { editable = "." } dependencies = [ - { name = "asyncpg" }, { name = "fastapi" }, - { name = "httpx" }, { name = "pydantic" }, { name = "sqlalchemy", extra = ["asyncio"] }, - { name = "typer" }, ] [package.optional-dependencies] +all = [ + { name = "asyncpg" }, + { name = "httpx" }, + { name = "prometheus-client" }, + { name = "pytest" }, + { name = "pytest-xdist" }, + { name = "typer" }, +] +asyncpg = [ + { name = "asyncpg" }, +] +cli = [ + { name = "typer" }, +] dev = [ + { name = "asyncpg" }, { name = "coverage" }, + { name = "httpx" }, { name = "prometheus-client" }, { name = "pytest" }, { name = "pytest-anyio" }, @@ -263,12 +276,19 @@ dev = [ { name = "pytest-xdist" }, { name = "ruff" }, { name = "ty" }, + { name = "typer" }, ] metrics = [ { name = "prometheus-client" }, ] +pytest = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-xdist" }, +] test = [ { name = "coverage" }, + { name = "httpx" }, { name = "pytest" }, { name = "pytest-anyio" }, { name = "pytest-cov" }, @@ -277,23 +297,25 @@ test = [ [package.metadata] requires-dist = [ - { name = "asyncpg", specifier = ">=0.29.0" }, + { name = "asyncpg", marker = "extra == 'asyncpg'", specifier = ">=0.29.0" }, { name = "coverage", marker = "extra == 'test'", specifier = ">=7.0.0" }, { name = "fastapi", specifier = ">=0.100.0" }, - { name = "fastapi-toolsets", extras = ["metrics", "test"], marker = "extra == 'dev'" }, - { name = "httpx", specifier = ">=0.25.0" }, + { name = "fastapi-toolsets", extras = ["all", "test"], marker = "extra == 'dev'" }, + { name = "fastapi-toolsets", extras = ["asyncpg", "cli", "metrics", "pytest"], marker = "extra == 'all'" }, + { name = "fastapi-toolsets", extras = ["pytest"], marker = "extra == 'test'" }, + { name = "httpx", marker = "extra == 'pytest'", specifier = ">=0.25.0" }, { name = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.20.0" }, { name = "pydantic", specifier = ">=2.0" }, - { name = "pytest", marker = "extra == 'test'", specifier = ">=8.0.0" }, + { name = "pytest", marker = "extra == 'pytest'", specifier = ">=8.0.0" }, { name = "pytest-anyio", marker = "extra == 'test'", specifier = ">=0.0.0" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=4.0.0" }, - { name = "pytest-xdist", marker = "extra == 'test'", specifier = ">=3.0.0" }, + { name = "pytest-xdist", marker = "extra == 'pytest'", specifier = ">=3.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" }, { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1a0" }, - { name = "typer", specifier = ">=0.9.0" }, + { name = "typer", marker = "extra == 'cli'", specifier = ">=0.9.0" }, ] -provides-extras = ["metrics", "test", "dev"] +provides-extras = ["asyncpg", "cli", "metrics", "pytest", "all", "test", "dev"] [[package]] name = "greenlet"