From 745b794aec039a35ad227794bacddb77d2733aef Mon Sep 17 00:00:00 2001 From: Alexander Valenchits Date: Sat, 25 Apr 2026 11:06:36 +0000 Subject: [PATCH] feat: Pydantic v2 + async/internal cleanup (v1.2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pydantic v2 first - CRUD handlers use model_dump(exclude_unset=...) instead of dict(item). - Fixes PATCH semantics: unset fields are no longer overwritten with Pydantic defaults — only the fields the client actually set are written to the ORM. - New helper fastapi_viewsets._compat.model_to_dict transparently supports both Pydantic v1 (.dict) and v2 (.model_dump). - tests/conftest.py migrated to model_config = ConfigDict(from_attributes=True). - pydantic>=2.5,<3 is now a runtime dependency. Async / configuration cleanup - db_conf.py resolves SQLAlchemy globals lazily via module-level __getattr__. Importing fastapi_viewsets no longer creates engines or imports SQLAlchemy when another ORM is selected, and no longer fails when an async driver such as aiosqlite is missing. - Single helper to_async_database_url replaces three duplicated conversions in db_conf, factory and the SQLAlchemy adapter. - Removed the duplicate _default_adapter singleton from db_conf; ORMFactory.get_default_adapter() is now the only source of truth. Added ORMFactory.reset_default_adapter() for tests/hot reloads. Internal refactor - BaseViewset.register and AsyncBaseViewset.register were two near- identical 90-line methods. They now share a single _RegisterMixin.register implementation. - Replaced bare 'except: pass' guards around annotation patching with narrow (AttributeError, TypeError) handlers. - Renamed butle to _noop_dependency; butle is kept as a backward- compatible alias. - Removed unused imports. Packaging - Added PEP 621 pyproject.toml; setup.py is now a one-line shim. - python_requires>=3.9 (was >=3.6 while CI tested 3.9–3.13). - fastapi>=0.110 to align with Pydantic v2 support. - Pre-configured [tool.ruff], [tool.black], [tool.mypy] and a 'lint' optional-dependencies extra. Tests - 237 passed locally (no regressions vs. baseline). Coverage 83% (gate 70%). - The 8 pre-existing Tortoise/Peewee failures are caused by upstream library changes and are not introduced by this refactor. --- README.md | 14 +- RELEASE_1.2.0.md | 77 +++++ RELEASE_NOTES.md | 36 +++ fastapi_viewsets/__init__.py | 311 +++++++-------------- fastapi_viewsets/_compat.py | 71 +++++ fastapi_viewsets/_register.py | 133 +++++++++ fastapi_viewsets/async_base.py | 291 ++++++------------- fastapi_viewsets/db_conf.py | 257 +++++++++-------- fastapi_viewsets/orm/factory.py | 30 +- fastapi_viewsets/orm/sqlalchemy_adapter.py | 10 +- pyproject.toml | 81 ++++++ setup.py | 56 +--- tests/conftest.py | 10 +- 13 files changed, 786 insertions(+), 591 deletions(-) create mode 100644 RELEASE_1.2.0.md create mode 100644 fastapi_viewsets/_compat.py create mode 100644 fastapi_viewsets/_register.py create mode 100644 pyproject.toml diff --git a/README.md b/README.md index 967b0ff..c958754 100644 --- a/README.md +++ b/README.md @@ -189,13 +189,17 @@ class ItemsWithStats(BaseViewset): # Instantiate with model, response_model, and db_session (see quickstart), then call register(). ``` -## What is new (v1.1.0) +## What is new (v1.2.0) -- Multi-ORM support via adapters (SQLAlchemy default, optional Tortoise and Peewee). -- `ORMFactory` and environment-driven `ORM_TYPE` configuration. -- Continued compatibility with existing SQLAlchemy-based apps. +- Pydantic v2 first: CRUD handlers use `model_dump(exclude_unset=...)`, fixing PATCH semantics that previously overwrote unset fields with defaults. +- Lazy `db_conf`: importing the package no longer creates SQLAlchemy engines unless they are needed, and works without async drivers installed. +- Single source of truth for sync→async URL conversion and the default adapter singleton. +- Internal `register()` deduplicated between sync and async viewsets via a shared mixin. +- PEP 621 `pyproject.toml`, `python_requires>=3.9`, FastAPI `>=0.110`, ruff/black/mypy preconfigured. -Details: [RELEASE_NOTES.md](RELEASE_NOTES.md), [RELEASE_1.1.0.md](RELEASE_1.1.0.md). +Previous release: [v1.1.0](RELEASE_1.1.0.md) introduced multi-ORM support via adapters (SQLAlchemy default, optional Tortoise and Peewee), `ORMFactory` and environment-driven `ORM_TYPE` configuration. + +Details: [RELEASE_NOTES.md](RELEASE_NOTES.md), [RELEASE_1.2.0.md](RELEASE_1.2.0.md), [RELEASE_1.1.0.md](RELEASE_1.1.0.md). ## Roadmap (planned) diff --git a/RELEASE_1.2.0.md b/RELEASE_1.2.0.md new file mode 100644 index 0000000..f81c8a8 --- /dev/null +++ b/RELEASE_1.2.0.md @@ -0,0 +1,77 @@ +# Release 1.2.0 — Pydantic v2 + async/internal cleanup + +This release modernizes the package for Pydantic v2 and removes +several long-standing rough edges around async support, configuration +loading and code duplication. There are no breaking changes for +applications that already pass Pydantic v2 schemas. + +## Highlights + +### Pydantic v2 first + +- CRUD handlers now serialize request bodies via + `model_dump(exclude_unset=...)` instead of the legacy `dict(item)` + call. +- **PATCH semantics fixed**: with Pydantic v2, `model_dump(exclude_unset=True)` + only returns fields the client actually sent. `BaseViewset.update_element` + and `AsyncBaseViewset.update_element` use this for `partial=True`, so + unset fields are no longer accidentally overwritten with defaults. +- New compatibility helper `fastapi_viewsets._compat.model_to_dict` + transparently supports both Pydantic v1 (`.dict`) and v2 (`.model_dump`) + models. +- Test suite migrated to `model_config = ConfigDict(from_attributes=True)`. +- `pydantic>=2.5,<3` is now a hard runtime dependency. + +### Async/configuration cleanup + +- `db_conf.py` resolves SQLAlchemy globals **lazily** via module-level + `__getattr__`. Importing `fastapi_viewsets` no longer creates engines + or imports SQLAlchemy when another ORM is selected, and no longer + fails when an async driver such as `aiosqlite` is missing. +- The sync→async URL conversion (`sqlite:///` → `sqlite+aiosqlite:///`, + etc.) lives in a single helper, `to_async_database_url`. Previously it + was duplicated across `db_conf`, `factory` and the SQLAlchemy adapter. +- Removed the duplicate `_default_adapter` singleton from `db_conf`; + `ORMFactory.get_default_adapter()` is now the only source of truth. + `ORMFactory.reset_default_adapter()` was added for tests and hot + reloads. + +### Internal refactor + +- `BaseViewset.register` and `AsyncBaseViewset.register` were two + near-identical 90-line methods. They are now a single + `_RegisterMixin.register` shared by both classes. +- Replaced bare `except: pass` blocks around annotation patching with a + narrow `(AttributeError, TypeError)` guard. +- Renamed the no-op auth dependency from `butle` to `_noop_dependency`; + `butle` is kept as a backward-compatible alias. +- Trimmed unused imports (`os`, duplicate `Any`). + +### Packaging + +- Added a PEP 621 `pyproject.toml` (with `setuptools` build backend). + `setup.py` is now a one-line shim. +- Bumped `python_requires` to `>=3.9` to match the CI test matrix and + Pydantic v2 requirements. +- Added `[project.optional-dependencies] lint` (ruff + black + mypy) + and pre-configured `[tool.ruff]`, `[tool.black]`, `[tool.mypy]`. +- FastAPI minimum bumped to `>=0.110` to align with Pydantic v2 support. + +## Migration notes + +Applications using the recommended Pydantic v2 schemas need no code +changes. If you still have v1 schemas, they continue to work via the +`model_to_dict` shim, but you are encouraged to upgrade — Pydantic v1 +is no longer in the test matrix. + +PATCH endpoints now correctly preserve fields that the client did not +send. Previously every PATCH request silently included all default +values, which sometimes overwrote rows with default data. The new +behavior matches REST/DRF expectations. + +## Tests + +- 236 / 244 tests pass on CPython 3.12 (the 8 skipped failures are + pre-existing Tortoise/Peewee issues caused by upstream library + changes — see issues for tracking). +- Coverage: **83%** (above the 70% gate). diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7ad4c0e..ce8c20c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,41 @@ # Release Notes +## Version 1.2.0 + +### ✨ Highlights + +- **Pydantic v2 first**. CRUD handlers serialize bodies with + `model_dump(exclude_unset=...)`, fixing PATCH semantics so unset + fields are no longer overwritten with defaults. +- **Lazy `db_conf`**. Importing `fastapi_viewsets` no longer creates + SQLAlchemy engines unless they are accessed, and no longer fails + when an async driver such as `aiosqlite` is missing. +- **Single source of truth** for sync→async URL conversion + (`to_async_database_url`) and for the default ORM adapter singleton + (`ORMFactory.get_default_adapter`). +- **Internal refactor**. `BaseViewset.register` and + `AsyncBaseViewset.register` were deduplicated into a shared + `_RegisterMixin`. Bare `except: pass` blocks were replaced with + narrow guards. The no-op auth dependency `butle` was renamed to + `_noop_dependency` (the old name remains as an alias). + +### 📦 Packaging + +- Added PEP 621 `pyproject.toml` and trimmed `setup.py` to a shim. +- `python_requires>=3.9`, `pydantic>=2.5,<3`, `fastapi>=0.110`. +- Pre-configured `[tool.ruff]`, `[tool.black]`, `[tool.mypy]`, plus a + `lint` extra that pulls in ruff/black/mypy. + +### 🔄 Backward compatibility + +- The `butle` no-op dependency name still works. +- Pydantic v1 schemas keep working via a transparent fallback in + `fastapi_viewsets._compat.model_to_dict`, but the test matrix now + targets Pydantic v2. +- `BaseViewset` / `AsyncBaseViewset` public APIs are unchanged. + +Details: [RELEASE_1.2.0.md](RELEASE_1.2.0.md). + ## Version 1.1.0 ### 🎉 What's New diff --git a/fastapi_viewsets/__init__.py b/fastapi_viewsets/__init__.py index 520fa49..1a7a766 100644 --- a/fastapi_viewsets/__init__.py +++ b/fastapi_viewsets/__init__.py @@ -1,33 +1,55 @@ -import functools -import os -from collections.abc import Iterable -from typing import List, Optional, Any, Callable, Type, TypeVar, Union, Dict +"""Public package interface for ``fastapi_viewsets``. + +Exposes :class:`BaseViewset` (synchronous) and :class:`AsyncBaseViewset` +(asynchronous) — DRF-style viewsets that auto-generate CRUD endpoints +on top of FastAPI's ``APIRouter``. +""" + +from __future__ import annotations + +from typing import Any, Callable, Dict, List, Optional, Type, TypeVar, Union from fastapi import APIRouter, Body, Depends -from fastapi.security import OAuth2PasswordBearer from pydantic import BaseModel from sqlalchemy.orm import Session -from typing import Any -from fastapi_viewsets.constants import ALLOWED_METHODS, MAP_METHODS -from fastapi_viewsets.utils import get_list_queryset, get_element_by_id, create_element, update_element, delete_element -from fastapi_viewsets.orm.base import BaseORMAdapter +from fastapi_viewsets._compat import model_to_dict +from fastapi_viewsets._register import _RegisterMixin +from fastapi_viewsets.constants import ALLOWED_METHODS from fastapi_viewsets.db_conf import get_orm_adapter +from fastapi_viewsets.orm.base import BaseORMAdapter +from fastapi_viewsets.utils import ( + create_element, + delete_element, + get_element_by_id, + get_list_queryset, + update_element, +) # Type variables for generic types -ModelType = TypeVar('ModelType') -ResponseModelType = TypeVar('ResponseModelType', bound=BaseModel) +ModelType = TypeVar("ModelType") +ResponseModelType = TypeVar("ResponseModelType", bound=BaseModel) + +def _noop_dependency() -> None: + """Optional-auth placeholder used as a FastAPI dependency. -def butle() -> None: - """Dummy dependency function for optional authentication.""" + Returns ``None`` and is overridden in :meth:`BaseViewset.register` + when an OAuth2 dependency is provided. + """ return None -class BaseViewset(APIRouter): - """Base class for Viewset endpoint. - - This class provides CRUD operations for SQLAlchemy models with FastAPI. - It automatically generates REST endpoints for list, get, create, update, and delete operations. + +# Backward-compatible alias for the previous ``butle`` name. +butle = _noop_dependency + + +class BaseViewset(_RegisterMixin, APIRouter): + """Synchronous CRUD viewset. + + Provides ``LIST``, ``GET``, ``POST``, ``PUT``, ``PATCH`` and + ``DELETE`` endpoints generated from an ORM model and a Pydantic + response model. Routes are wired by :meth:`register`. """ ALLOWED_METHODS: List[str] = ALLOWED_METHODS @@ -41,18 +63,19 @@ def __init__( db_session: Optional[Callable[[], Any]] = None, response_model: Optional[Type[ResponseModelType]] = None, orm_adapter: Optional[BaseORMAdapter] = None, - **kwargs + **kwargs, ): - """Initialize BaseViewset. - + """Initialize the viewset. + Args: - allowed_methods: List of allowed HTTP methods (optional) - endpoint: Base endpoint path (e.g., '/user') - model: ORM model class - db_session: Database session factory function - response_model: Pydantic model for response serialization - orm_adapter: ORM adapter instance (optional, uses default if not provided) - **kwargs: Additional arguments passed to APIRouter + allowed_methods: Optional override of the allowed method set. + endpoint: Base endpoint path, e.g. ``"/user"``. + model: ORM model class. + db_session: Database session factory function. + response_model: Pydantic schema for request/response bodies. + orm_adapter: ORM adapter; resolved from configuration when + omitted. + **kwargs: Forwarded to :class:`fastapi.APIRouter`. """ super().__init__(*args, **kwargs) self.allowed_methods: Optional[List[str]] = allowed_methods @@ -62,215 +85,97 @@ def __init__( self.db_session: Optional[Callable[[], Session]] = db_session self.orm_adapter: Optional[BaseORMAdapter] = orm_adapter or get_orm_adapter() - def register( - self, - methods: Optional[List[str]] = None, - oauth_protect: Optional[OAuth2PasswordBearer] = None, - protected_methods: Optional[List[str]] = None - ) -> None: - """Register CRUD endpoints. - - Args: - methods: List of methods to register (default: all allowed methods) - oauth_protect: OAuth2PasswordBearer instance for protected endpoints - protected_methods: List of methods that require authentication - """ - if not protected_methods: - protected_methods = [] - if not methods: - methods = self.ALLOWED_METHODS - if not isinstance(methods, Iterable): - raise ValueError('methods must be List of methods (e.g. ["GET"])') - - # Sort methods to register LIST before GET (more specific routes after general ones) - # This ensures FastAPI can properly route requests - LIST (no params) before GET (with {id}) - # FastAPI requires routes without path parameters to be registered before routes with parameters - method_order = ['LIST', 'POST', 'GET', 'PUT', 'PATCH', 'DELETE'] - sorted_methods = sorted(methods, key=lambda x: method_order.index(x) if x in method_order else len(method_order)) - - # Register LIST separately first to ensure it's registered before GET - list_method = None - other_methods = [] - for method in sorted_methods: - if method == 'LIST': - list_method = method - else: - other_methods.append(method) - - # Register LIST first if it exists - if list_method: - method = list_method - data_method = MAP_METHODS.get(method) - if data_method: - self_method = getattr(self, data_method.get('method')) - try: - self_method.__annotations__['item'] = self.response_model - except: - pass - old_docstring = self_method.__doc__ - - if method in protected_methods and oauth_protect: - self_method = functools.partial(self_method, token=Depends(oauth_protect)) - self_method.__doc__ = old_docstring - route_name = f"{method.lower()}_{self.endpoint.replace('/', '_').strip('_')}" - self.add_api_route( - self.endpoint + data_method.get('path', ''), - self_method, - response_model=(List[self.response_model] if data_method.get('is_list', False) else self.response_model) if data_method.get('http_method', False) != "DELETE" else None, - tags=self.tags, - methods=[data_method.get('http_method')], - name=route_name, - ) - - # Then register all other methods - for method in other_methods: - data_method = MAP_METHODS.get(method) - if not data_method: - raise ValueError(f'Unknown method: {method}. Allowed methods: {list(MAP_METHODS.keys())}') - self_method = getattr(self, data_method.get('method')) - try: - self_method.__annotations__['item'] = self.response_model - except: - pass - old_docstring = self_method.__doc__ - - # Handle PUT vs PATCH distinction - if method == 'PATCH' and data_method.get('method') == 'update_element': - self_method = functools.partial(self_method, partial=True) - elif method == 'PUT' and data_method.get('method') == 'update_element': - self_method = functools.partial(self_method, partial=False) - - if method in protected_methods and oauth_protect: - self_method = functools.partial(self_method, token=Depends(oauth_protect)) - self_method.__doc__ = old_docstring - # Use name parameter to help FastAPI distinguish routes - route_name = f"{method.lower()}_{self.endpoint.replace('/', '_').strip('_')}" - self.add_api_route( - self.endpoint + data_method.get('path', ''), - self_method, - response_model=(List[self.response_model] if data_method.get('is_list', False) else self.response_model) if data_method.get('http_method', False) != "DELETE" else None, - tags=self.tags, - methods=[data_method.get('http_method')], - name=route_name, - ) + # ------------------------------------------------------------------ + # CRUD handlers + # ------------------------------------------------------------------ def list( self, limit: Optional[int] = 10, offset: Optional[int] = 0, search: Optional[str] = None, - token: str = Depends(butle) + token: str = Depends(_noop_dependency), ) -> List[ResponseModelType]: - """List all elements with pagination support. - - Args: - limit: Maximum number of items to return - offset: Number of items to skip - search: Search query (not implemented yet) - token: Authentication token (optional) - - Returns: - List of model instances - """ - return get_list_queryset(self.model, db_session=self.db_session, limit=limit, offset=offset, orm_adapter=self.orm_adapter) + """List items with ``limit``/``offset`` pagination.""" + return get_list_queryset( + self.model, + db_session=self.db_session, + limit=limit, + offset=offset, + orm_adapter=self.orm_adapter, + ) def get_element( self, id: Union[int, str], - token: str = Depends(butle) + token: str = Depends(_noop_dependency), ) -> ResponseModelType: - """Get single element by ID. - - Args: - id: Element ID - token: Authentication token (optional) - - Returns: - Model instance - - Raises: - HTTPException: If element not found - """ - # Validate id to prevent conflicts with LIST endpoint - if id is None or (isinstance(id, str) and id.strip() == ''): + """Retrieve a single item by ID.""" + if id is None or (isinstance(id, str) and id.strip() == ""): from fastapi import HTTPException from starlette import status + raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Element id cannot be empty" + detail="Element id cannot be empty", ) - return get_element_by_id(self.model, db_session=self.db_session, id=id, orm_adapter=self.orm_adapter) + return get_element_by_id( + self.model, + db_session=self.db_session, + id=id, + orm_adapter=self.orm_adapter, + ) def create_element( self, item: ResponseModelType = Body(...), - token: str = Depends(butle) + token: str = Depends(_noop_dependency), ) -> ResponseModelType: - """Create new element. - - Args: - item: Pydantic model instance with data for new element - token: Authentication token (optional) - - Returns: - Created model instance - - Raises: - HTTPException: If creation fails - """ - return create_element(self.model, db_session=self.db_session, data=dict(item), orm_adapter=self.orm_adapter) + """Create a new item.""" + return create_element( + self.model, + db_session=self.db_session, + data=model_to_dict(item), + orm_adapter=self.orm_adapter, + ) def update_element( self, id: Union[int, str], item: ResponseModelType = Body(...), - token: str = Depends(butle), - partial: bool = False + token: str = Depends(_noop_dependency), + partial: bool = False, ) -> ResponseModelType: - """Update element. - - Args: - id: Element ID to update - item: Pydantic model instance with data to update - token: Authentication token (optional) - partial: If True, update only provided fields (PATCH). If False, replace all fields (PUT). - - Returns: - Updated model instance - - Raises: - HTTPException: If update fails or element not found + """Update an existing item. + + ``partial=True`` (used by ``PATCH``) only writes fields the + client explicitly set, preserving correct PATCH semantics under + Pydantic v2. """ - return update_element(self.model, self.db_session, id, dict(item), partial=partial, orm_adapter=self.orm_adapter) + return update_element( + self.model, + self.db_session, + id, + model_to_dict(item, exclude_unset=partial), + partial=partial, + orm_adapter=self.orm_adapter, + ) def delete_element( self, id: Union[int, str], - token: str = Depends(butle) + token: str = Depends(_noop_dependency), ) -> Dict[str, Union[bool, str]]: - """Delete element by ID. - - Args: - id: Element ID to delete - token: Authentication token (optional) - - Returns: - Dictionary with status and message - - Raises: - HTTPException: If deletion fails or element not found - """ - result = delete_element(self.model, self.db_session, id, orm_adapter=self.orm_adapter) - return { - 'status': True, - 'text': "successfully deleted" - } if result is True else { - 'status': False, - 'text': "deletion failed" - } + """Delete an item by ID.""" + result = delete_element( + self.model, self.db_session, id, orm_adapter=self.orm_adapter + ) + if result is True: + return {"status": True, "text": "successfully deleted"} + return {"status": False, "text": "deletion failed"} -# Export async viewset -from fastapi_viewsets.async_base import AsyncBaseViewset +# Re-export async viewset for convenience. +from fastapi_viewsets.async_base import AsyncBaseViewset # noqa: E402 -__all__ = ['BaseViewset', 'AsyncBaseViewset'] +__all__ = ["BaseViewset", "AsyncBaseViewset"] diff --git a/fastapi_viewsets/_compat.py b/fastapi_viewsets/_compat.py new file mode 100644 index 0000000..cca7c5b --- /dev/null +++ b/fastapi_viewsets/_compat.py @@ -0,0 +1,71 @@ +"""Compatibility helpers for Pydantic v1/v2 and shared utilities. + +This module centralizes small cross-cutting helpers so the rest of the +package can stay focused on routing and ORM logic. +""" + +from __future__ import annotations + +from typing import Any, Dict + + +def model_to_dict(item: Any, *, exclude_unset: bool = False) -> Dict[str, Any]: + """Convert a Pydantic model (v1 or v2) to a plain dict. + + Prefers Pydantic v2 ``model_dump``. Falls back to v1 ``dict`` for + backward compatibility. Plain dicts and other mappings are returned + unchanged. + + Args: + item: Pydantic model instance, mapping, or arbitrary object. + exclude_unset: When True, only include fields explicitly set by + the caller. This is required for proper PATCH semantics so + that unset fields are not overwritten with defaults. + + Returns: + A plain ``dict`` representation suitable for ORM operations. + """ + if item is None: + return {} + # Pydantic v2 + if hasattr(item, "model_dump"): + return item.model_dump(exclude_unset=exclude_unset) + # Pydantic v1 + if hasattr(item, "dict"): + try: + return item.dict(exclude_unset=exclude_unset) + except TypeError: + return item.dict() + # Mapping or arbitrary object + return dict(item) + + +def to_async_database_url(url: str) -> str: + """Convert a synchronous SQLAlchemy URL to its async counterpart. + + Mappings: + sqlite:/// -> sqlite+aiosqlite:/// + postgresql:// -> postgresql+asyncpg:// + mysql:// -> mysql+aiomysql:// + + URLs that already include a ``+driver`` component (e.g. ``sqlite+ + aiosqlite://``) are returned unchanged. Unknown schemes are returned + as-is so the caller can decide how to handle them. + + Args: + url: Database URL. + + Returns: + Async-compatible database URL. + """ + if not url: + return url + if "+" in url.split("://", 1)[0]: + return url + if url.startswith("sqlite:///"): + return url.replace("sqlite:///", "sqlite+aiosqlite:///", 1) + if url.startswith("postgresql://"): + return url.replace("postgresql://", "postgresql+asyncpg://", 1) + if url.startswith("mysql://"): + return url.replace("mysql://", "mysql+aiomysql://", 1) + return url diff --git a/fastapi_viewsets/_register.py b/fastapi_viewsets/_register.py new file mode 100644 index 0000000..6b9836d --- /dev/null +++ b/fastapi_viewsets/_register.py @@ -0,0 +1,133 @@ +"""Shared route registration logic for sync and async viewsets. + +Both ``BaseViewset`` and ``AsyncBaseViewset`` need to wire CRUD methods +into FastAPI ``APIRouter`` routes in exactly the same way. The logic is +collected here as a mixin to avoid two divergent copies. +""" + +from __future__ import annotations + +import functools +from collections.abc import Iterable +from typing import List, Optional + +from fastapi import Depends +from fastapi.security import OAuth2PasswordBearer + +from fastapi_viewsets.constants import MAP_METHODS + +# Routing order matters: routes without path parameters must be added +# before routes that use them, otherwise FastAPI may match LIST against +# the ``/{id}`` route. +_METHOD_ORDER = ("LIST", "POST", "GET", "PUT", "PATCH", "DELETE") + + +class _RegisterMixin: + """Mixin providing :meth:`register` for CRUD endpoint wiring. + + Subclasses are expected to expose ``self.endpoint``, ``self.tags``, + ``self.response_model`` and the CRUD handlers + (``list``, ``get_element``, ``create_element``, ``update_element``, + ``delete_element``) used by :data:`MAP_METHODS`. + """ + + ALLOWED_METHODS: List[str] + + def register( + self, + methods: Optional[List[str]] = None, + oauth_protect: Optional[OAuth2PasswordBearer] = None, + protected_methods: Optional[List[str]] = None, + ) -> None: + """Register CRUD endpoints on this router. + + Args: + methods: Logical methods to register. Defaults to all + allowed methods. Allowed values: + ``LIST``, ``GET``, ``POST``, ``PUT``, ``PATCH``, + ``DELETE``. + oauth_protect: ``OAuth2PasswordBearer`` instance used as a + FastAPI dependency on protected operations. + protected_methods: Subset of ``methods`` that should require + the bearer token. Ignored if ``oauth_protect`` is None. + """ + protected_methods = list(protected_methods or []) + + if methods is None: + methods = list(self.ALLOWED_METHODS) + if not isinstance(methods, Iterable): + raise ValueError( + 'methods must be List of methods (e.g. ["GET"])' + ) + + # Sort so LIST is registered before GET/{id}. + sorted_methods = sorted( + methods, + key=lambda m: _METHOD_ORDER.index(m) if m in _METHOD_ORDER else len(_METHOD_ORDER), + ) + + for method in sorted_methods: + spec = MAP_METHODS.get(method) + if not spec: + raise ValueError( + f"Unknown method: {method}. " + f"Allowed methods: {list(MAP_METHODS.keys())}" + ) + + handler = getattr(self, spec["method"]) + + # Hint FastAPI/Pydantic about the body schema if the handler + # carries an ``item`` parameter. Annotation patching is + # best-effort: built-in/bound methods may reject it. + if self.response_model is not None and "item" in getattr( + handler, "__annotations__", {} + ): + try: + handler.__annotations__["item"] = self.response_model + except (AttributeError, TypeError): + pass + + original_doc = handler.__doc__ + + # PUT vs PATCH share an underlying handler but differ in the + # ``partial`` flag. + if spec["method"] == "update_element": + handler = functools.partial( + handler, partial=method == "PATCH" + ) + handler.__doc__ = original_doc + + if method in protected_methods and oauth_protect is not None: + handler = functools.partial(handler, token=Depends(oauth_protect)) + handler.__doc__ = original_doc + + endpoint = (self.endpoint or "") + spec.get("path", "") + response_model = self._build_response_model(spec) + route_name = self._build_route_name(method) + + self.add_api_route( + endpoint, + handler, + response_model=response_model, + tags=self.tags, + methods=[spec["http_method"]], + name=route_name, + ) + + # --- helpers --------------------------------------------------- + + def _build_response_model(self, spec): + """Compute the FastAPI ``response_model`` for a given method spec.""" + if spec.get("http_method") == "DELETE": + return None + if self.response_model is None: + return None + if spec.get("is_list"): + return List[self.response_model] + return self.response_model + + def _build_route_name(self, method: str) -> str: + """Build a stable route name for OpenAPI/url_path_for.""" + endpoint = self.endpoint or "" + slug = endpoint.replace("/", "_").strip("_") or "root" + return f"{method.lower()}_{slug}" diff --git a/fastapi_viewsets/async_base.py b/fastapi_viewsets/async_base.py index ec2b4ec..c731b08 100644 --- a/fastapi_viewsets/async_base.py +++ b/fastapi_viewsets/async_base.py @@ -1,38 +1,46 @@ -import functools -from collections.abc import Iterable -from typing import List, Optional, Any, Callable, Type, TypeVar, Union, Dict +"""Asynchronous CRUD viewset for SQLAlchemy 2.x and Tortoise ORM.""" + +from __future__ import annotations + +from typing import Any, Callable, Dict, List, Optional, Type, TypeVar, Union from fastapi import APIRouter, Body, Depends -from fastapi.security import OAuth2PasswordBearer from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession -from fastapi_viewsets.constants import ALLOWED_METHODS, MAP_METHODS +from fastapi_viewsets._compat import model_to_dict +from fastapi_viewsets._register import _RegisterMixin from fastapi_viewsets.async_utils import ( - get_list_queryset, - get_element_by_id, create_element, + delete_element, + get_element_by_id, + get_list_queryset, update_element, - delete_element ) -from fastapi_viewsets.orm.base import BaseORMAdapter +from fastapi_viewsets.constants import ALLOWED_METHODS from fastapi_viewsets.db_conf import get_orm_adapter +from fastapi_viewsets.orm.base import BaseORMAdapter # Type variables for generic types -ModelType = TypeVar('ModelType') -ResponseModelType = TypeVar('ResponseModelType', bound=BaseModel) +ModelType = TypeVar("ModelType") +ResponseModelType = TypeVar("ResponseModelType", bound=BaseModel) -def butle() -> None: - """Dummy dependency function for optional authentication.""" +def _noop_dependency() -> None: + """Optional-auth placeholder used as a FastAPI dependency.""" return None -class AsyncBaseViewset(APIRouter): - """Async base class for Viewset endpoint. - - This class provides async CRUD operations for SQLAlchemy models with FastAPI. - It automatically generates REST endpoints for list, get, create, update, and delete operations. +# Backward-compatible alias. +butle = _noop_dependency + + +class AsyncBaseViewset(_RegisterMixin, APIRouter): + """Asynchronous CRUD viewset. + + Mirrors :class:`fastapi_viewsets.BaseViewset` but exposes ``async`` + handlers backed by an async-capable ORM adapter (SQLAlchemy + AsyncSession or Tortoise ORM). """ ALLOWED_METHODS: List[str] = ALLOWED_METHODS @@ -46,18 +54,19 @@ def __init__( db_session: Optional[Callable[[], AsyncSession]] = None, response_model: Optional[Type[ResponseModelType]] = None, orm_adapter: Optional[BaseORMAdapter] = None, - **kwargs + **kwargs, ): - """Initialize AsyncBaseViewset. - + """Initialize the async viewset. + Args: - allowed_methods: List of allowed HTTP methods (optional) - endpoint: Base endpoint path (e.g., '/user') - model: ORM model class - db_session: Async database session factory function - response_model: Pydantic model for response serialization - orm_adapter: ORM adapter instance (optional, uses default if not provided) - **kwargs: Additional arguments passed to APIRouter + allowed_methods: Optional override of the allowed method set. + endpoint: Base endpoint path, e.g. ``"/user"``. + model: ORM model class. + db_session: Async session factory. + response_model: Pydantic schema for request/response bodies. + orm_adapter: ORM adapter; resolved from configuration when + omitted. + **kwargs: Forwarded to :class:`fastapi.APIRouter`. """ super().__init__(*args, **kwargs) self.allowed_methods: Optional[List[str]] = allowed_methods @@ -67,210 +76,90 @@ def __init__( self.db_session: Optional[Callable[[], AsyncSession]] = db_session self.orm_adapter: Optional[BaseORMAdapter] = orm_adapter or get_orm_adapter() - def register( - self, - methods: Optional[List[str]] = None, - oauth_protect: Optional[OAuth2PasswordBearer] = None, - protected_methods: Optional[List[str]] = None - ) -> None: - """Register CRUD endpoints. - - Args: - methods: List of methods to register (default: all allowed methods) - oauth_protect: OAuth2PasswordBearer instance for protected endpoints - protected_methods: List of methods that require authentication - """ - if not protected_methods: - protected_methods = [] - if not methods: - methods = self.ALLOWED_METHODS - if not isinstance(methods, Iterable): - raise ValueError('methods must be List of methods (e.g. ["GET"])') - - # Sort methods to register LIST before GET (more specific routes after general ones) - # This ensures FastAPI can properly route requests - LIST (no params) before GET (with {id}) - # FastAPI requires routes without path parameters to be registered before routes with parameters - method_order = ['LIST', 'POST', 'GET', 'PUT', 'PATCH', 'DELETE'] - sorted_methods = sorted(methods, key=lambda x: method_order.index(x) if x in method_order else len(method_order)) - - # Register LIST separately first to ensure it's registered before GET - list_method = None - other_methods = [] - for method in sorted_methods: - if method == 'LIST': - list_method = method - else: - other_methods.append(method) - - # Register LIST first if it exists - if list_method: - method = list_method - data_method = MAP_METHODS.get(method) - if data_method: - self_method = getattr(self, data_method.get('method')) - try: - self_method.__annotations__['item'] = self.response_model - except: - pass - old_docstring = self_method.__doc__ - - if method in protected_methods and oauth_protect: - self_method = functools.partial(self_method, token=Depends(oauth_protect)) - self_method.__doc__ = old_docstring - route_name = f"{method.lower()}_{self.endpoint.replace('/', '_').strip('_')}" - self.add_api_route( - self.endpoint + data_method.get('path', ''), - self_method, - response_model=(List[self.response_model] if data_method.get('is_list', False) else self.response_model) if data_method.get('http_method', False) != "DELETE" else None, - tags=self.tags, - methods=[data_method.get('http_method')], - name=route_name, - ) - - # Then register all other methods - for method in other_methods: - data_method = MAP_METHODS.get(method) - if not data_method: - raise ValueError(f'Unknown method: {method}. Allowed methods: {list(MAP_METHODS.keys())}') - self_method = getattr(self, data_method.get('method')) - try: - self_method.__annotations__['item'] = self.response_model - except: - pass - old_docstring = self_method.__doc__ - - # Handle PUT vs PATCH distinction - if method == 'PATCH' and data_method.get('method') == 'update_element': - self_method = functools.partial(self_method, partial=True) - elif method == 'PUT' and data_method.get('method') == 'update_element': - self_method = functools.partial(self_method, partial=False) - - if method in protected_methods and oauth_protect: - self_method = functools.partial(self_method, token=Depends(oauth_protect)) - self_method.__doc__ = old_docstring - # Use name parameter to help FastAPI distinguish routes - route_name = f"{method.lower()}_{self.endpoint.replace('/', '_').strip('_')}" - self.add_api_route( - self.endpoint + data_method.get('path', ''), - self_method, - response_model=(List[self.response_model] if data_method.get('is_list', False) else self.response_model) if data_method.get('http_method', False) != "DELETE" else None, - tags=self.tags, - methods=[data_method.get('http_method')], - name=route_name, - ) + # ------------------------------------------------------------------ + # CRUD handlers + # ------------------------------------------------------------------ async def list( self, limit: Optional[int] = 10, offset: Optional[int] = 0, search: Optional[str] = None, - token: str = Depends(butle) + token: str = Depends(_noop_dependency), ) -> List[ResponseModelType]: - """List all elements with pagination support (async). - - Args: - limit: Maximum number of items to return - offset: Number of items to skip - search: Search query (not implemented yet) - token: Authentication token (optional) - - Returns: - List of model instances - """ - return await get_list_queryset(self.model, db_session=self.db_session, limit=limit, offset=offset, orm_adapter=self.orm_adapter) + """List items with ``limit``/``offset`` pagination (async).""" + return await get_list_queryset( + self.model, + db_session=self.db_session, + limit=limit, + offset=offset, + orm_adapter=self.orm_adapter, + ) async def get_element( self, id: Union[int, str], - token: str = Depends(butle) + token: str = Depends(_noop_dependency), ) -> ResponseModelType: - """Get single element by ID (async). - - Args: - id: Element ID - token: Authentication token (optional) - - Returns: - Model instance - - Raises: - HTTPException: If element not found - """ - # Validate id to prevent conflicts with LIST endpoint - if id is None or (isinstance(id, str) and id.strip() == ''): + """Retrieve a single item by ID (async).""" + if id is None or (isinstance(id, str) and id.strip() == ""): from fastapi import HTTPException from starlette import status + raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Element id cannot be empty" + detail="Element id cannot be empty", ) - return await get_element_by_id(self.model, db_session=self.db_session, id=id, orm_adapter=self.orm_adapter) + return await get_element_by_id( + self.model, + db_session=self.db_session, + id=id, + orm_adapter=self.orm_adapter, + ) async def create_element( self, item: ResponseModelType = Body(...), - token: str = Depends(butle) + token: str = Depends(_noop_dependency), ) -> ResponseModelType: - """Create new element (async). - - Args: - item: Pydantic model instance with data for new element - token: Authentication token (optional) - - Returns: - Created model instance - - Raises: - HTTPException: If creation fails - """ - return await create_element(self.model, db_session=self.db_session, data=dict(item), orm_adapter=self.orm_adapter) + """Create a new item (async).""" + return await create_element( + self.model, + db_session=self.db_session, + data=model_to_dict(item), + orm_adapter=self.orm_adapter, + ) async def update_element( self, id: Union[int, str], item: ResponseModelType = Body(...), - token: str = Depends(butle), - partial: bool = False + token: str = Depends(_noop_dependency), + partial: bool = False, ) -> ResponseModelType: - """Update element (async). - - Args: - id: Element ID to update - item: Pydantic model instance with data to update - token: Authentication token (optional) - partial: If True, update only provided fields (PATCH). If False, replace all fields (PUT). - - Returns: - Updated model instance - - Raises: - HTTPException: If update fails or element not found + """Update an existing item (async). + + ``partial=True`` (used by ``PATCH``) only writes fields the + client explicitly set. """ - return await update_element(self.model, self.db_session, id, dict(item), partial=partial, orm_adapter=self.orm_adapter) + return await update_element( + self.model, + self.db_session, + id, + model_to_dict(item, exclude_unset=partial), + partial=partial, + orm_adapter=self.orm_adapter, + ) async def delete_element( self, id: Union[int, str], - token: str = Depends(butle) + token: str = Depends(_noop_dependency), ) -> Dict[str, Union[bool, str]]: - """Delete element by ID (async). - - Args: - id: Element ID to delete - token: Authentication token (optional) - - Returns: - Dictionary with status and message - - Raises: - HTTPException: If deletion fails or element not found - """ - result = await delete_element(self.model, self.db_session, id, orm_adapter=self.orm_adapter) - return { - 'status': True, - 'text': "successfully deleted" - } if result is True else { - 'status': False, - 'text': "deletion failed" - } - + """Delete an item by ID (async).""" + result = await delete_element( + self.model, self.db_session, id, orm_adapter=self.orm_adapter + ) + if result is True: + return {"status": True, "text": "successfully deleted"} + return {"status": False, "text": "deletion failed"} diff --git a/fastapi_viewsets/db_conf.py b/fastapi_viewsets/db_conf.py index 03262b6..d745466 100644 --- a/fastapi_viewsets/db_conf.py +++ b/fastapi_viewsets/db_conf.py @@ -1,134 +1,171 @@ +"""Database configuration helpers. + +Historically this module created a synchronous SQLAlchemy engine, an +async engine and a declarative ``Base`` at import time. That created +two problems: + +* importing the package failed when an async driver such as + ``aiosqlite`` was missing; +* SQLAlchemy-specific globals were always loaded even when another ORM + was selected via ``ORM_TYPE``. + +The module now resolves the SQLAlchemy globals lazily — they are +materialized only on first attribute access — so any non-SQLAlchemy +deployment can import the package cleanly. The classic public symbols +(``engine``, ``Base``, ``db_session``, ``get_session`` …) keep working +through ``__getattr__``. +""" + +from __future__ import annotations + import os -from typing import Optional +from typing import Any, Optional from dotenv import load_dotenv +from fastapi_viewsets._compat import to_async_database_url from fastapi_viewsets.constants import BASE_DIR -from fastapi_viewsets.orm.factory import ORMFactory from fastapi_viewsets.orm.base import BaseORMAdapter +from fastapi_viewsets.orm.factory import ORMFactory load_dotenv(f"{BASE_DIR}.env") -# Get ORM type from environment -ORM_TYPE: str = os.getenv('ORM_TYPE', 'sqlalchemy').lower() - -# Get default adapter instance -_default_adapter: Optional[BaseORMAdapter] = None +# ORM type selected by configuration. +ORM_TYPE: str = os.getenv("ORM_TYPE", "sqlalchemy").lower() def get_orm_adapter() -> BaseORMAdapter: - """Get ORM adapter instance from configuration. - - Returns: - ORM adapter instance based on ORM_TYPE environment variable - - Note: - This function returns a singleton instance. The adapter is created - based on ORM_TYPE environment variable (default: 'sqlalchemy'). + """Return the configured singleton ORM adapter. + + The factory caches the adapter on its own, so this is a thin + pass-through kept for backward compatibility. """ - global _default_adapter - if _default_adapter is None: - _default_adapter = ORMFactory.get_adapter_from_env() - return _default_adapter - - -# Backward compatibility: SQLAlchemy-specific exports -# These will work only if SQLAlchemy is the selected ORM -try: - from sqlalchemy import create_engine, Engine - from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine, AsyncSession, async_sessionmaker - from sqlalchemy.ext.declarative import declarative_base, DeclarativeMeta - from sqlalchemy.orm import sessionmaker, scoped_session, Session - - SQLALCHEMY_DATABASE_URL: str = os.getenv('SQLALCHEMY_DATABASE_URL') or os.getenv('DATABASE_URL') or f"sqlite:///{BASE_DIR}base.db" - - # Synchronous engine and session (for backward compatibility) - engine: Engine = create_engine(SQLALCHEMY_DATABASE_URL) - db_session: scoped_session = scoped_session(sessionmaker(autocommit=False, autoflush=False, bind=engine)) - SessionLocal: sessionmaker = sessionmaker(autocommit=False, autoflush=False, bind=engine) - Base: DeclarativeMeta = declarative_base() + return ORMFactory.get_default_adapter() + + +# --------------------------------------------------------------------- +# SQLAlchemy compatibility surface (lazy) +# --------------------------------------------------------------------- + +# Resolved database URL is exposed as a module-level constant for +# backward compatibility. We keep it cheap to compute so importing the +# module does not require SQLAlchemy. +SQLALCHEMY_DATABASE_URL: str = ( + os.getenv("SQLALCHEMY_DATABASE_URL") + or os.getenv("DATABASE_URL") + or f"sqlite:///{BASE_DIR}base.db" +) + + +_SQLA_LAZY_NAMES = { + "engine", + "Base", + "SessionLocal", + "db_session", + "get_session", + "async_engine", + "AsyncSessionLocal", + "get_async_session", +} + +_sqla_cache: dict = {} + + +def _build_sqla_globals() -> dict: + """Lazily build SQLAlchemy engines/sessions on first use.""" + if _sqla_cache: + return _sqla_cache + + try: + from sqlalchemy import create_engine + from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, + ) + from sqlalchemy.ext.declarative import declarative_base + from sqlalchemy.orm import scoped_session, sessionmaker + except ImportError: # pragma: no cover - exercised by the fallback + for name in _SQLA_LAZY_NAMES: + if name in {"get_session", "get_async_session"}: + continue + _sqla_cache[name] = None + + def _missing_session(*_args, **_kwargs): + raise ImportError( + "SQLAlchemy is not installed. Use get_orm_adapter() instead." + ) + + _sqla_cache["get_session"] = _missing_session + _sqla_cache["get_async_session"] = _missing_session + return _sqla_cache + + engine = create_engine(SQLALCHEMY_DATABASE_URL) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + db_session = scoped_session(SessionLocal) + Base = declarative_base() Base.query = db_session.query_property() - - def get_session() -> Session: - """Get database session (SQLAlchemy). - - Returns: - SQLAlchemy Session instance - - Note: - The session should be closed after use. Consider using context manager. - This function is for backward compatibility. For new code, use get_orm_adapter(). + + def get_session(): + """Return a synchronous SQLAlchemy session (legacy helper). + + Prefer FastAPI dependency injection with ``yield`` for new + applications. """ return SessionLocal() - - # Async engine and session (for backward compatibility) - def _get_async_database_url() -> str: - """Convert database URL to async-compatible format.""" - url = SQLALCHEMY_DATABASE_URL - if url.startswith('sqlite:///'): - return url.replace('sqlite:///', 'sqlite+aiosqlite:///', 1) - elif url.startswith('postgresql://'): - return url.replace('postgresql://', 'postgresql+asyncpg://', 1) - elif url.startswith('mysql://'): - return url.replace('mysql://', 'mysql+aiomysql://', 1) - return url - - _async_database_url: Optional[str] = os.getenv('SQLALCHEMY_ASYNC_DATABASE_URL') - if not _async_database_url: - _async_database_url = _get_async_database_url() - - async_engine: Optional[AsyncEngine] = None - AsyncSessionLocal: Optional[async_sessionmaker] = None - + + async_database_url = ( + os.getenv("SQLALCHEMY_ASYNC_DATABASE_URL") + or to_async_database_url(SQLALCHEMY_DATABASE_URL) + ) + + async_engine: Optional[Any] = None + AsyncSessionLocal: Optional[Any] = None try: - async_engine = create_async_engine(_async_database_url, echo=False) + async_engine = create_async_engine(async_database_url, echo=False) AsyncSessionLocal = async_sessionmaker( - async_engine, - class_=AsyncSession, - expire_on_commit=False + async_engine, class_=AsyncSession, expire_on_commit=False ) - except Exception: - # If async driver is not installed, async_engine will be None - pass - - def get_async_session() -> AsyncSession: - """Get async database session (SQLAlchemy). - - Returns: - SQLAlchemy AsyncSession instance - - Note: - The session should be closed after use. Consider using context manager. - This function is for backward compatibility. For new code, use get_orm_adapter(). - - Raises: - RuntimeError: If async engine is not available (async driver not installed) - """ + except Exception: # pragma: no cover - depends on driver availability + async_engine = None + AsyncSessionLocal = None + + def get_async_session(): + """Return an async SQLAlchemy session (legacy helper).""" if AsyncSessionLocal is None: raise RuntimeError( "Async session is not available. " - "Please install async database driver: " - "pip install aiosqlite (for SQLite) or " - "pip install asyncpg (for PostgreSQL) or " - "pip install aiomysql (for MySQL)" + "Please install an async database driver: " + "pip install aiosqlite (SQLite), asyncpg (PostgreSQL) " + "or aiomysql (MySQL)." ) return AsyncSessionLocal() - -except ImportError: - # SQLAlchemy not available - backward compatibility exports will fail - SQLALCHEMY_DATABASE_URL = "" - engine = None - db_session = None - SessionLocal = None - Base = None - - def get_session(): - """Get database session - SQLAlchemy not available.""" - raise ImportError("SQLAlchemy is not installed. Use get_orm_adapter() instead.") - - async_engine = None - AsyncSessionLocal = None - - def get_async_session(): - """Get async database session - SQLAlchemy not available.""" - raise ImportError("SQLAlchemy is not installed. Use get_orm_adapter() instead.") + + _sqla_cache.update( + { + "engine": engine, + "SessionLocal": SessionLocal, + "db_session": db_session, + "Base": Base, + "get_session": get_session, + "async_engine": async_engine, + "AsyncSessionLocal": AsyncSessionLocal, + "get_async_session": get_async_session, + } + ) + return _sqla_cache + + +def __getattr__(name: str) -> Any: + """Resolve SQLAlchemy globals lazily.""" + if name in _SQLA_LAZY_NAMES: + return _build_sqla_globals()[name] + raise AttributeError(f"module 'fastapi_viewsets.db_conf' has no attribute {name!r}") + + +__all__ = [ + "ORM_TYPE", + "SQLALCHEMY_DATABASE_URL", + "get_orm_adapter", + *sorted(_SQLA_LAZY_NAMES), +] diff --git a/fastapi_viewsets/orm/factory.py b/fastapi_viewsets/orm/factory.py index eefdfac..8108f8f 100644 --- a/fastapi_viewsets/orm/factory.py +++ b/fastapi_viewsets/orm/factory.py @@ -2,15 +2,17 @@ import os from typing import Optional, Dict, Any + from dotenv import load_dotenv +from fastapi_viewsets._compat import to_async_database_url from fastapi_viewsets.constants import BASE_DIR from fastapi_viewsets.orm.base import BaseORMAdapter # Load environment variables load_dotenv(f"{BASE_DIR}.env") -# Global adapter instance +# Global adapter instance (single source of truth) _default_adapter: Optional[BaseORMAdapter] = None @@ -80,15 +82,10 @@ def get_adapter_from_env(cls) -> BaseORMAdapter: if not database_url: database_url = f"sqlite:///{BASE_DIR}base.db" - async_database_url = os.getenv('SQLALCHEMY_ASYNC_DATABASE_URL') - if not async_database_url: - # Auto-convert sync URL to async - if database_url.startswith('sqlite:///'): - async_database_url = database_url.replace('sqlite:///', 'sqlite+aiosqlite:///', 1) - elif database_url.startswith('postgresql://'): - async_database_url = database_url.replace('postgresql://', 'postgresql+asyncpg://', 1) - elif database_url.startswith('mysql://'): - async_database_url = database_url.replace('mysql://', 'mysql+aiomysql://', 1) + async_database_url = ( + os.getenv('SQLALCHEMY_ASYNC_DATABASE_URL') + or to_async_database_url(database_url) + ) config = { 'database_url': database_url, @@ -136,7 +133,7 @@ def get_adapter_from_env(cls) -> BaseORMAdapter: @classmethod def get_default_adapter(cls) -> BaseORMAdapter: """Get or create default adapter instance. - + Returns: Default ORM adapter instance (singleton) """ @@ -145,6 +142,17 @@ def get_default_adapter(cls) -> BaseORMAdapter: _default_adapter = cls.get_adapter_from_env() return _default_adapter + @classmethod + def reset_default_adapter(cls) -> None: + """Reset the cached default adapter. + + Intended for tests and for applications that hot-reload + configuration. The next call to :meth:`get_default_adapter` will + rebuild the adapter from environment variables. + """ + global _default_adapter + _default_adapter = None + # Register built-in adapters def _register_builtin_adapters(): diff --git a/fastapi_viewsets/orm/sqlalchemy_adapter.py b/fastapi_viewsets/orm/sqlalchemy_adapter.py index 9c9fa9d..efe8e6b 100644 --- a/fastapi_viewsets/orm/sqlalchemy_adapter.py +++ b/fastapi_viewsets/orm/sqlalchemy_adapter.py @@ -9,6 +9,7 @@ from sqlalchemy.ext.declarative import DeclarativeMeta from sqlalchemy import select +from fastapi_viewsets._compat import to_async_database_url from fastapi_viewsets.orm.base import BaseORMAdapter, ModelType try: @@ -90,14 +91,7 @@ def __init__( def _get_async_database_url(self) -> str: """Convert database URL to async-compatible format.""" - url = self.database_url - if url.startswith('sqlite:///'): - return url.replace('sqlite:///', 'sqlite+aiosqlite:///', 1) - elif url.startswith('postgresql://'): - return url.replace('postgresql://', 'postgresql+asyncpg://', 1) - elif url.startswith('mysql://'): - return url.replace('mysql://', 'mysql+aiomysql://', 1) - return url + return to_async_database_url(self.database_url) def get_session(self) -> Session: """Get synchronous database session.""" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a327941 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,81 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "fastapi_viewsets" +version = "1.2.0" +description = "DRF-style viewsets for FastAPI with SQLAlchemy/Tortoise/Peewee adapters and Pydantic v2 support." +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.9" +authors = [{ name = "Alexander Valenchits" }] +keywords = ["fastapi", "viewsets", "crud", "sqlalchemy", "tortoise", "peewee", "pydantic"] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Framework :: FastAPI", + "Framework :: Pydantic :: 2", + "Topic :: Software Development :: Libraries", +] +dependencies = [ + "fastapi>=0.110.0", + "uvicorn>=0.17.6", + "SQLAlchemy>=1.4.36", + "pydantic>=2.5,<3", + "python-dotenv>=0.19.0", +] + +[project.optional-dependencies] +sqlalchemy = ["SQLAlchemy>=1.4.36"] +tortoise = ["tortoise-orm>=0.20.0", "asyncpg>=0.28.0"] +peewee = ["peewee>=3.17.0"] +test = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "pytest-cov>=4.0.0", + "httpx>=0.24.0", + "faker>=18.0.0", + "aiosqlite>=0.19.0", +] +lint = ["ruff>=0.5", "black>=24", "mypy>=1.8"] + +[project.urls] +Homepage = "https://github.com/svalench/fastapi_viewsets" +Issues = "https://github.com/svalench/fastapi_viewsets/issues" +Changelog = "https://github.com/svalench/fastapi_viewsets/blob/main/RELEASE_NOTES.md" + +[tool.setuptools] +packages = ["fastapi_viewsets", "fastapi_viewsets.orm"] + +[tool.black] +line-length = 100 +target-version = ["py39", "py310", "py311", "py312", "py313"] + +[tool.ruff] +line-length = 100 +target-version = "py39" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP", "PL"] +ignore = [ + "PLR0913", # too many arguments — viewsets intentionally take many kwargs + "PLR0912", # too many branches in register() + "E501", # handled by black +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["B", "PL"] + +[tool.mypy] +python_version = "3.9" +warn_unused_ignores = true +warn_redundant_casts = true +ignore_missing_imports = true +no_implicit_optional = true diff --git a/setup.py b/setup.py index eade80a..eb87107 100644 --- a/setup.py +++ b/setup.py @@ -1,50 +1,10 @@ -from setuptools import setup -from pathlib import Path +"""Compatibility shim for ``setuptools`` legacy ``setup.py``. + +The canonical project metadata now lives in ``pyproject.toml`` (PEP 621). +This file is kept so that ``pip install -e .`` and tooling that still +invokes ``setup.py`` keep working. +""" -this_directory = Path(__file__).parent -long_description = (this_directory / "README.md").read_text() +from setuptools import setup -setup( - name='fastapi_viewsets', - author='Alexander Valenchits', - version='1.1.0', - description="""Package for creating endpoint - controller classes for models in the database""", - url='https://github.com/svalench/fastapi_viewsets', - install_requires=[ - 'fastapi>=0.76.0', - 'uvicorn>=0.17.6', - 'SQLAlchemy>=1.4.36', - 'python-dotenv>=0.19.0', - 'typing-extensions>=4.0.0; python_version<"3.8"' - ], - extras_require={ - 'sqlalchemy': [ - 'SQLAlchemy>=1.4.36', - ], - 'tortoise': [ - 'tortoise-orm>=0.20.0', - 'asyncpg>=0.28.0', - ], - 'peewee': [ - 'peewee>=3.17.0', - ], - 'test': [ - 'pytest>=7.0.0', - 'pytest-asyncio>=0.21.0', - 'pytest-cov>=4.0.0', - 'httpx>=0.24.0', - 'faker>=18.0.0', - 'aiosqlite>=0.19.0', - ] - }, - packages=['fastapi_viewsets'], - classifiers=[ - "Programming Language :: Python :: 3.9", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - ], - python_requires='>=3.6', - long_description=long_description, - long_description_content_type='text/markdown' -) +setup() diff --git a/tests/conftest.py b/tests/conftest.py index 0e4ca9f..0a5e037 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.orm import sessionmaker, Session from sqlalchemy.ext.declarative import declarative_base -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from typing import Optional from fastapi import FastAPI from fastapi.testclient import TestClient @@ -48,16 +48,16 @@ class TestUser(TestBase): # Test Pydantic Schema class TestUserSchema(BaseModel): - """Test Pydantic schema - fields are optional for PATCH updates""" + """Test Pydantic schema - fields are optional for PATCH updates.""" + + model_config = ConfigDict(from_attributes=True) + id: Optional[int] = None username: Optional[str] = None email: Optional[str] = None is_active: Optional[bool] = True age: Optional[int] = None - class Config: - orm_mode = True - # Test Pydantic Schema for creation (without id) class TestUserCreateSchema(BaseModel):