diff --git a/README.md b/README.md index 0f26614..9df5557 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ CI - + Documentation @@ -103,7 +103,7 @@ zc run Your API is live at **`http://127.0.0.1:8000`** with 7 secure endpoints (CRUD + Dynamic Search) ready. -> 📖 **Full walkthrough:** [Quick Start Guide](https://baseryn.github.io/zcore-docs/docs/quick-start) +> 📖 **Full walkthrough:** [Quick Start Guide](https://zcore.baseryn.com/docs/quick-start) --- @@ -124,7 +124,7 @@ class TaskResponse(Zchema): id: uuid.UUID title: str - salary: float # Automatically pruned if unauthorized + salary: float | None = None # Automatically pruned if unauthorized ``` When an authenticated user lacks view permissions for `tasks.salary`, the field vanishes from both JSON serialization and the OpenAPI schema — with zero manual `if` statements. @@ -229,7 +229,7 @@ The `zc` CLI gives you a rich, interactive Terminal User Interface (TUI) powered ```text $ zc -⚡ ZCore Framework v0.1.0-Beta • Modern Modular Monolith +⚡ ZCore Framework v0.1.0-rc.1 • Modern Modular Monolith FastAPI • SQLAlchemy 2.0 • Pydantic V2 ? What framework task would you like to perform? 📦 init — Scaffold a new full ZCore project @@ -260,12 +260,12 @@ $ zc | Resource | Description | |----------|-------------| -| [🚀 Quick Start](https://baseryn.github.io/zcore-docs/docs/quick-start) | Build a complete Task Manager API from scratch | -| [📚 10-Step Quick Learn](https://baseryn.github.io/zcore-docs/docs/quick-learn/step-1) | Deep dive into each architectural layer step-by-step | -| [🔧 How-To Guides](https://baseryn.github.io/zcore-docs/docs/how-to) | Pagination, search, file uploads, caching, and testing | -| [🏛️ Core Concepts](https://baseryn.github.io/zcore-docs/docs/core-concepts/context) | Deep dive into DI, Kernel, Security, UoW, and Zchema | -| [📜 API Reference](https://baseryn.github.io/zcore-docs/docs/api-reference/repository) | Complete class and method specifications | -| [🕒 Changelog](https://baseryn.github.io/zcore-docs/docs/changelog) | Release notes, breaking changes, and migrations | +| [🚀 Quick Start](https://zcore.baseryn.com/docs/quick-start) | Build a complete Task Manager API from scratch | +| [📚 10-Step Quick Learn](https://zcore.baseryn.com/docs/quick-learn/step-1) | Deep dive into each architectural layer step-by-step | +| [🔧 How-To Guides](https://zcore.baseryn.com/docs/how-to) | Pagination, search, file uploads, caching, and testing | +| [🏛️ Core Concepts](https://zcore.baseryn.com/docs/core-concepts/context) | Deep dive into DI, Kernel, Security, UoW, and Zchema | +| [📜 API Reference](https://zcore.baseryn.com/docs/api-reference/repository) | Complete class and method specifications | +| [🕒 Changelog](https://zcore.baseryn.com/docs/changelog) | Release notes, breaking changes, and migrations | --- diff --git a/pyproject.toml b/pyproject.toml index 0634e4c..7f679fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,10 +10,10 @@ readme = "README.md" requires-python = ">=3.11" license = { text = "Apache License 2.0" } authors = [ - { name = "Ali Alf Ostovar", email = "240363843+alialfostovar@users.noreply.github.com" } + { name = "Ali Alf Ostovar"} ] maintainers = [ - { name = "Baseryn" } + { name = "Baseryn", email = "support@baseryn.com"} ] classifiers = [ "Development Status :: 4 - Beta", @@ -36,6 +36,7 @@ dependencies = [ "uvicorn>=0.22.0", "questionary>=2.0.1", "rich>=13.7.0", + "tzdata>=2024.1; sys_platform == 'win32'", ] [project.optional-dependencies] @@ -80,12 +81,13 @@ all = [ "aiofiles>=23.2.1", "argon2-cffi>=23.1.0", "pyjwt>=2.8.0", + "tzdata>=2024.1; sys_platform == 'win32'", ] [project.urls] Homepage = "https://github.com/Baseryn/zcore" Repository = "https://github.com/Baseryn/zcore" -Documentation = "https://baseryn.github.io/zcore-docs/" +Documentation = "https://zcore.baseryn.com" "Bug Tracker" = "https://github.com/Baseryn/zcore/issues" [project.scripts] diff --git a/src/zcore/__init__.py b/src/zcore/__init__.py index 84fb960..77fdcec 100644 --- a/src/zcore/__init__.py +++ b/src/zcore/__init__.py @@ -1,56 +1,132 @@ +"""ZCore Framework Core Package Entrypoint.""" + from typing import TYPE_CHECKING, Any from zcore.config import settings if TYPE_CHECKING: + from zcore.config import ( + DatabaseSettings, + LoggingSettings, + Settings, + get_settings, + initialize_settings, + ) from zcore.context.context import ZContext, ctx - from zcore.db.repository import BaseRepository - from zcore.db.search import SearchRequest - from zcore.db.setup import Base, SessionDep, db_manager, get_db + from zcore.db.events import dispatch_db_event, register_db_event_dispatcher + from zcore.db.pagination import ( + BasePagination, + CursorPagination, + CursorParams, + PageNumberPagination, + PageNumberParams, + PaginatedResult, + ) + from zcore.db.repository import AbstractRepository, BaseRepository + from zcore.db.search import SearchEngine, SearchRequest + from zcore.db.setup import Actions, Base, SessionDep, db_manager, get_db from zcore.db.soft_delete import SoftDeleteMixin from zcore.db.uow import UnitOfWork - from zcore.kernel.di import Inject, container + from zcore.kernel.di import ( + Inject, + Injector, + IoCContainer, + background_scope, + background_task, + container, + ) from zcore.kernel.engine import Kernel from zcore.kernel.events import EventDispatcher, on_event from zcore.kernel.plugins import Plugin from zcore.security.auth import BaseAuth + from zcore.security.permissions import BasePermission, HasScopes + from zcore.security.protocols import UserProtocol from zcore.security.security import Security - from zcore.service.base import BaseService - from zcore.web.api_router import ZCoreAPIRoute + from zcore.service.base import AbstractService, BaseService + from zcore.storage.base import StorageProvider, get_storage_provider + from zcore.utils.helpers import CustomJSONEncoder, json_dumps, json_loads, slugify + from zcore.utils.timezone import ( + ZDateTime, + format_iso_with_app_timezone, + get_app_timezone, + now, + to_app_timezone, + utc_now, + ) + from zcore.web.api_router import ZCoreAPIRoute, ZCoreJSONResponse, ZCoreRequest from zcore.web.base_router import BaseRouter, RouteKey from zcore.web.projection import Zchema from zcore.web.response import ResponseWrapper __all__ = [ + "AbstractRepository", + "AbstractService", + "Actions", "Base", "BaseAuth", + "BasePagination", + "BasePermission", "BaseRepository", "BaseRouter", "BaseService", + "CursorPagination", + "CursorParams", + "CustomJSONEncoder", + "DatabaseSettings", "EventDispatcher", + "HasScopes", "Inject", + "Injector", + "IoCContainer", "Kernel", + "LoggingSettings", + "PageNumberPagination", + "PageNumberParams", + "PaginatedResult", "Plugin", "ResponseWrapper", "RouteKey", + "SearchEngine", "SearchRequest", "Security", "SessionDep", + "Settings", "SoftDeleteMixin", + "StorageProvider", "UnitOfWork", + "UserProtocol", "ZContext", "ZCoreAPIRoute", + "ZCoreJSONResponse", + "ZCoreRequest", + "ZDateTime", "Zchema", + "background_scope", + "background_task", "container", "ctx", "db_manager", + "dispatch_db_event", + "format_iso_with_app_timezone", + "get_app_timezone", "get_db", + "get_settings", + "get_storage_provider", + "initialize_settings", + "json_dumps", + "json_loads", + "now", "on_event", + "register_db_event_dispatcher", "settings", + "slugify", + "to_app_timezone", + "utc_now", ] def __getattr__(name: str) -> Any: + """Dynamically resolve and lazily load framework components to prevent circular imports.""" if name == "Kernel": from zcore.kernel.engine import Kernel @@ -63,6 +139,22 @@ def __getattr__(name: str) -> Any: from zcore.kernel.di import Inject return Inject + if name == "Injector": + from zcore.kernel.di import Injector + + return Injector + if name == "IoCContainer": + from zcore.kernel.di import IoCContainer + + return IoCContainer + if name == "background_scope": + from zcore.kernel.di import background_scope + + return background_scope + if name == "background_task": + from zcore.kernel.di import background_task + + return background_task if name == "Base": from zcore.db.setup import Base @@ -79,10 +171,18 @@ def __getattr__(name: str) -> Any: from zcore.db.setup import get_db return get_db + if name == "Actions": + from zcore.db.setup import Actions + + return Actions if name == "BaseRepository": from zcore.db.repository import BaseRepository return BaseRepository + if name == "AbstractRepository": + from zcore.db.repository import AbstractRepository + + return AbstractRepository if name == "UnitOfWork": from zcore.db.uow import UnitOfWork @@ -91,10 +191,18 @@ def __getattr__(name: str) -> Any: from zcore.db.search import SearchRequest return SearchRequest + if name == "SearchEngine": + from zcore.db.search import SearchEngine + + return SearchEngine if name == "BaseService": from zcore.service.base import BaseService return BaseService + if name == "AbstractService": + from zcore.service.base import AbstractService + + return AbstractService if name == "BaseRouter": from zcore.web.base_router import BaseRouter @@ -107,6 +215,14 @@ def __getattr__(name: str) -> Any: from zcore.web.api_router import ZCoreAPIRoute return ZCoreAPIRoute + if name == "ZCoreJSONResponse": + from zcore.web.api_router import ZCoreJSONResponse + + return ZCoreJSONResponse + if name == "ZCoreRequest": + from zcore.web.api_router import ZCoreRequest + + return ZCoreRequest if name == "Zchema": from zcore.web.projection import Zchema @@ -135,6 +251,18 @@ def __getattr__(name: str) -> Any: from zcore.security.auth import BaseAuth return BaseAuth + if name == "BasePermission": + from zcore.security.permissions import BasePermission + + return BasePermission + if name == "HasScopes": + from zcore.security.permissions import HasScopes + + return HasScopes + if name == "UserProtocol": + from zcore.security.protocols import UserProtocol + + return UserProtocol if name == "on_event": from zcore.kernel.events import on_event @@ -143,10 +271,109 @@ def __getattr__(name: str) -> Any: from zcore.kernel.events import EventDispatcher return EventDispatcher - if name == "SoftDeleteMixin": from zcore.db.soft_delete import SoftDeleteMixin return SoftDeleteMixin + if name == "DatabaseSettings": + from zcore.config import DatabaseSettings + + return DatabaseSettings + if name == "LoggingSettings": + from zcore.config import LoggingSettings + + return LoggingSettings + if name == "Settings": + from zcore.config import Settings + + return Settings + if name == "get_settings": + from zcore.config import get_settings + + return get_settings + if name == "initialize_settings": + from zcore.config import initialize_settings + + return initialize_settings + if name == "ZDateTime": + from zcore.utils.timezone import ZDateTime + + return ZDateTime + if name == "now": + from zcore.utils.timezone import now + + return now + if name == "utc_now": + from zcore.utils.timezone import utc_now + + return utc_now + if name == "to_app_timezone": + from zcore.utils.timezone import to_app_timezone + + return to_app_timezone + if name == "get_app_timezone": + from zcore.utils.timezone import get_app_timezone + + return get_app_timezone + if name == "format_iso_with_app_timezone": + from zcore.utils.timezone import format_iso_with_app_timezone + + return format_iso_with_app_timezone + if name == "PaginatedResult": + from zcore.db.pagination import PaginatedResult + + return PaginatedResult + if name == "PageNumberParams": + from zcore.db.pagination import PageNumberParams + + return PageNumberParams + if name == "CursorParams": + from zcore.db.pagination import CursorParams + + return CursorParams + if name == "BasePagination": + from zcore.db.pagination import BasePagination + + return BasePagination + if name == "PageNumberPagination": + from zcore.db.pagination import PageNumberPagination + + return PageNumberPagination + if name == "CursorPagination": + from zcore.db.pagination import CursorPagination + + return CursorPagination + if name == "dispatch_db_event": + from zcore.db.events import dispatch_db_event + + return dispatch_db_event + if name == "register_db_event_dispatcher": + from zcore.db.events import register_db_event_dispatcher + + return register_db_event_dispatcher + if name == "StorageProvider": + from zcore.storage.base import StorageProvider + + return StorageProvider + if name == "get_storage_provider": + from zcore.storage.base import get_storage_provider + + return get_storage_provider + if name == "json_dumps": + from zcore.utils.helpers import json_dumps + + return json_dumps + if name == "json_loads": + from zcore.utils.helpers import json_loads + + return json_loads + if name == "slugify": + from zcore.utils.helpers import slugify + + return slugify + if name == "CustomJSONEncoder": + from zcore.utils.helpers import CustomJSONEncoder + + return CustomJSONEncoder - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") \ No newline at end of file diff --git a/src/zcore/cache/base.py b/src/zcore/cache/base.py index f0d8a36..99dec3c 100644 --- a/src/zcore/cache/base.py +++ b/src/zcore/cache/base.py @@ -3,10 +3,11 @@ This module provides a unified cache manager supporting distributed caching (via Redis) with an asynchronous, thread-safe local fallback (via `TTLLRUCache`). It coordinates global cache lifecycles, structured serialization and deserialization, and automatically -spawns background memory eviction routines. +spawns background memory eviction routines safely within active event loops. """ import asyncio +import contextlib from typing import Any, Generic, TypeVar import structlog @@ -29,18 +30,34 @@ _eviction_task: asyncio.Task | None = None +def _ensure_eviction_task(interval: int = 60) -> None: + """Schedule the background memory eviction task if an active event loop is running. + + Args: + interval: Rest period duration in seconds between garbage collection sweeps. + Defaults to 60. + """ + global _eviction_task + try: + loop = asyncio.get_running_loop() + if _eviction_task is None or _eviction_task.done(): + _eviction_task = loop.create_task(_start_eviction_loop(interval=interval)) + except RuntimeError: + pass + + def init_cache(redis_url: str | None = None, **kwargs: Any) -> None: """Initialize global distributed caching clients and local eviction workers. Configures a shared Redis connection client if library support and server configurations - are available. In addition, always schedules the active background memory eviction worker - loop to periodically purge expired local records. + are available. In addition, attempts to schedule the active background memory eviction worker + loop if an event loop is currently active. Args: redis_url: Connection URL pointing to a Redis server instance. Defaults to None. **kwargs: Connection pool parameters passed directly to the Redis client initialization. """ - global _shared_redis_client, _eviction_task + global _shared_redis_client if REDIS_AVAILABLE and redis_url: try: @@ -51,8 +68,7 @@ def init_cache(redis_url: str | None = None, **kwargs: Any) -> None: except Exception as e: logger.error(f"Failed to initialize shared Redis client: {e}") - if _eviction_task is None or _eviction_task.done(): - _eviction_task = asyncio.create_task(_start_eviction_loop(interval=60)) + _ensure_eviction_task() async def _start_eviction_loop(interval: int = 60) -> None: @@ -78,6 +94,8 @@ async def close_cache() -> None: if _eviction_task and not _eviction_task.done(): _eviction_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await _eviction_task _eviction_task = None logger.info("Cache memory eviction loop stopped.") @@ -149,6 +167,7 @@ async def get( The parsed data structure or validated Pydantic model instance, or None if the key is not found or fails to deserialize. """ + _ensure_eviction_task() full_key = self._get_key(key) client = self.redis_client raw_val = None @@ -189,6 +208,7 @@ async def set(self, key: str, value: T, ttl: int = 3600) -> None: value: The data payload to serialize and cache. ttl: Maximum cache duration in seconds. Defaults to 3600. """ + _ensure_eviction_task() full_key = self._get_key(key) client = self.redis_client @@ -209,6 +229,7 @@ async def delete(self, key: str) -> None: Args: key: The unique key identifier to evict. """ + _ensure_eviction_task() full_key = self._get_key(key) client = self.redis_client if client: @@ -218,4 +239,4 @@ async def delete(self, key: str) -> None: except Exception as e: logger.error(f"Redis delete failed in '{self.prefix}': {e}") - self._local_cache.delete(full_key) + self._local_cache.delete(full_key) \ No newline at end of file diff --git a/src/zcore/cli/__init__.py b/src/zcore/cli/__init__.py index 504c290..fce0d4a 100644 --- a/src/zcore/cli/__init__.py +++ b/src/zcore/cli/__init__.py @@ -1,3 +1,5 @@ +"""ZCore Command Line Interface Entrypoint.""" + import argparse import shutil import sys @@ -38,6 +40,7 @@ def resolve_target_project(subprojects: list[str], prompt_msg: str) -> Path | None: + """Prompt user to select a target project from detected workspace projects.""" print_step_header(prompt_msg) chosen = questionary.select( "Select target project:", @@ -48,6 +51,7 @@ def resolve_target_project(subprojects: list[str], prompt_msg: str) -> Path | No def prompt_init_interactive(project_name: str | None = None, db_driver: str | None = None) -> None: + """Prompt user interactively to scaffold a new ZCore project.""" print_step_header("Initialize New ZCore Project") if not project_name: @@ -99,6 +103,7 @@ def prompt_init_interactive(project_name: str | None = None, db_driver: str | No def prompt_startapp_interactive(app_name: str | None = None, target_dir: Path | None = None) -> None: + """Prompt user interactively to scaffold a domain application module.""" print_step_header("Scaffold ZCore Domain App") if not app_name: @@ -148,6 +153,7 @@ def prompt_startapp_interactive(app_name: str | None = None, target_dir: Path | def interactive_dashboard() -> None: + """Display interactive CLI dashboard for workspace management.""" print_banner() is_inside = is_zcore_project() @@ -212,6 +218,7 @@ def interactive_dashboard() -> None: def main() -> None: + """Parse arguments and execute CLI commands.""" try: parser = argparse.ArgumentParser(description="ZCore CLI Tool", prog="zc") parser.add_argument("--version", action="store_true", help="Show the active ZCore Framework version") @@ -239,7 +246,7 @@ def main() -> None: args = parser.parse_args() if args.version: - console.print(f"[bold {ZCORE_PRIMARY}]ZCore Framework[/bold {ZCORE_PRIMARY}] - Version [bold white]0.1.0-Beta[/bold white]") + console.print(f"[bold {ZCORE_PRIMARY}]ZCore Framework[/bold {ZCORE_PRIMARY}] - Version [bold white]0.1.0-rc.1[/bold white]") sys.exit(0) if args.command == "init": diff --git a/src/zcore/cli/templates.py b/src/zcore/cli/templates.py index a611828..35bc6fd 100644 --- a/src/zcore/cli/templates.py +++ b/src/zcore/cli/templates.py @@ -1,29 +1,20 @@ +"""ZCore CLI Scaffolding Templates.""" + MAIN_PY_TEMPLATE = """import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from fastapi import FastAPI -from zcore import Kernel, settings -from zcore.web import RequestLogMiddleware, ScopedDependencyMiddleware -from zcore.exceptions import app_exception_handler, AppException -from zcore.db import db_manager, register_db_event_dispatcher +from zcore import Kernel, db_manager, register_db_event_dispatcher, settings +from zcore.exceptions import AppException, app_exception_handler from zcore.logging import setup_logging +from zcore.web import RequestLogMiddleware, ScopedDependencyMiddleware -# Initialize Structured Logging setup_logging() -# Initialize Database Manager (SQLAlchemy Async Engine) -db_manager.init_app( - db_url=settings.DATABASE_URL, - pool_size=settings.POOL_SIZE, - max_overflow=settings.MAX_OVERFLOW, - echo=(settings.DEBUG) -) +db_manager.init_app(config=settings.DATABASE) -# Initialize ZCore Kernel & Plugins kernel = Kernel() - -# Register global database event dispatcher register_db_event_dispatcher(kernel.dispatcher) app = FastAPI( @@ -33,11 +24,9 @@ kernel.setup(app) -# Load Core Architectural Middlewares app.add_middleware(RequestLogMiddleware) app.add_middleware(ScopedDependencyMiddleware) -# Register Centralized Error Handlers app.add_exception_handler(AppException, app_exception_handler) @app.get("/") @@ -45,6 +34,7 @@ async def root(): return { "status": "healthy", "framework": "ZCore", + "version": "0.1.0-rc.1", "debug": settings.DEBUG } """ @@ -57,6 +47,7 @@ async def root(): # --- ZCore Application Settings --- PROJECT_NAME="{project_name}" DEBUG=True +TIMEZONE="UTC" DATABASE_URL=sqlite+aiosqlite:///zcore_dev.db DATABASE_TEST_URL=sqlite+aiosqlite:///zcore_test.db @@ -121,29 +112,23 @@ async def root(): .env.* """ -# --- App Module Templates --- - -MODEL_TEMPLATE = """from zcore import Base -from sqlalchemy.orm import Mapped, mapped_column +MODEL_TEMPLATE = """import uuid from sqlalchemy import String -import uuid +from sqlalchemy.orm import Mapped, mapped_column +from zcore import Base class {ModelName}(Base): __tablename__ = "{table_name}" id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - # TODO: Define your schema / columns below: - # name: Mapped[str] = mapped_column(String(255), index=True) """ -SCHEMA_TEMPLATE = """from zcore import Zchema +SCHEMA_TEMPLATE = """import uuid from pydantic import ConfigDict -import uuid +from zcore import Zchema class {ModelName}Base(Zchema): __model__ = "{table_name}" - # TODO: Add your shared model attributes - pass class {ModelName}Create({ModelName}Base): pass @@ -157,10 +142,11 @@ class {ModelName}Response({ModelName}Base): model_config = ConfigDict(from_attributes=True) """ -REPOSITORY_TEMPLATE = """from zcore import BaseRepository -from sqlalchemy.ext.asyncio import AsyncSession +REPOSITORY_TEMPLATE = """from sqlalchemy.ext.asyncio import AsyncSession +from zcore import BaseRepository from .models import {ModelName} +from .schemas import {ModelName}Create, {ModelName}Update class {ModelName}Repository(BaseRepository[{ModelName}]): def __init__(self, db: AsyncSession): @@ -179,11 +165,11 @@ def __init__(self, repository: {ModelName}Repository): ROUTER_TEMPLATE = """from typing import Any from zcore import BaseRouter, RouteKey -from .schemas import {ModelName}Create, {ModelName}Update, {ModelName}Response -from .services import {ModelName}Service from .models import {ModelName} +from .schemas import {ModelName}Create, {ModelName}Response, {ModelName}Update +from .services import {ModelName}Service -class {ModelName}Router(BaseRouter[{ModelName}Create, {ModelName}Update]): +class {ModelName}Router(BaseRouter): model = {ModelName} create_schema = {ModelName}Create update_schema = {ModelName}Update @@ -192,22 +178,10 @@ class {ModelName}Router(BaseRouter[{ModelName}Create, {ModelName}Update]): prefix = "/{app_name}" tags = ["{ModelName}"] - # expose_schemas = True # Set True to auto-expose JSON Schema on endpoints def get_route_dependencies(self, route_key: RouteKey, action: str) -> list[Any]: - \"\"\"Retrieve the dependencies list for the router endpoints. - - By overriding this method, you can dynamically inject custom authentication, - authorization, logging, or rate-limiting dependencies for specific route keys. - \"\"\" - # Example: Add custom dependencies to delete operation, fallback to standard permissions for others - if route_key == RouteKey.DELETE: - # return [MyCustomAdminPermission()] - pass - return super().get_route_dependencies(route_key, action) -# Export the FastAPI router router_instance = {ModelName}Router() """ @@ -217,36 +191,28 @@ def get_route_dependencies(self, route_key: RouteKey, action: str) -> list[Any]: class {ModelName}Plugin(Plugin): name = "{app_name}" version = "0.1.0" - dependencies = [] # Add dependent plugin names here (e.g. ['security_plugin']) + dependencies = [] def setup(self, app: FastAPI) -> None: - # Wire this module's sub-router directly to the central FastAPI app - - # from .routers import router_instance - # app.include_router(router_instance.router) pass async def before_startup(self) -> None: - # Executes before any other plugin starts pass async def on_startup(self) -> None: - # Standard startup logic (e.g. warming up local caches) pass async def after_startup(self) -> None: - # Post-startup cleanups pass async def on_shutdown(self) -> None: - # Cleanup tasks for this specific module pass """ -TEST_TEMPLATE = """import pytest -import uuid -from zcore.testing import ZTestClient +TEST_TEMPLATE = """import uuid +import pytest from main import app +from zcore.testing import ZTestClient @pytest.mark.asyncio async def test_get_{app_name}_list(): diff --git a/src/zcore/cli/ui.py b/src/zcore/cli/ui.py index e9a2b1a..48f5e81 100644 --- a/src/zcore/cli/ui.py +++ b/src/zcore/cli/ui.py @@ -1,3 +1,5 @@ +"""CLI User Interface Styling and Banners.""" + from questionary import Style from rich.console import Console @@ -21,10 +23,11 @@ def print_banner() -> None: + """Render the standard framework CLI banner with active versioning.""" console.print() console.print( f"[bold {ZCORE_PRIMARY}]⚡ ZCore Framework[/bold {ZCORE_PRIMARY}] " - f"[dim white]v0.1.0-Beta[/dim white] " + f"[dim white]v0.1.0-rc.1[/dim white] " f"[{ZCORE_MUTED}]• Modern Modular Monolith[/{ZCORE_MUTED}]" ) console.print(f" [dim {ZCORE_MUTED}]FastAPI • SQLAlchemy 2.0 • Pydantic V2[/dim {ZCORE_MUTED}]") @@ -32,13 +35,16 @@ def print_banner() -> None: def print_step_header(title: str) -> None: + """Print standard stylized step header box.""" console.print(f"[dim]┌[/dim] [bold {ZCORE_PRIMARY}]{title}[/bold {ZCORE_PRIMARY}]") console.print("[dim]│[/dim]") def print_step_footer(message: str) -> None: + """Print standard stylized step footer line.""" console.print(f"[dim]└[/dim] [bold {ZCORE_PRIMARY}]{message}[/bold {ZCORE_PRIMARY}]\n") def print_cancelled() -> None: + """Print standard stylized operation cancelled message.""" console.print("[dim]└[/dim] [red]Cancelled.[/red]\n") \ No newline at end of file diff --git a/src/zcore/config.py b/src/zcore/config.py index 04da4e7..54357d4 100644 --- a/src/zcore/config.py +++ b/src/zcore/config.py @@ -10,6 +10,7 @@ import os from typing import Any, TypeVar, cast +from pydantic import AliasChoices, BaseModel, Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from zcore.kernel.di import container @@ -17,24 +18,64 @@ T = TypeVar("T", bound="Settings") +class DatabaseSettings(BaseModel): + """Database connection and engine configuration schema.""" + + url: str = "sqlite+aiosqlite:///zcore.db" + pool_size: int = 5 + max_overflow: int = 10 + pool_recycle: int = 1800 + pool_pre_ping: bool = True + echo: bool = False + connect_args: dict[str, Any] = Field(default_factory=dict) + execution_options: dict[str, Any] = Field(default_factory=dict) + extra_engine_kwargs: dict[str, Any] = Field(default_factory=dict) + + +class LoggingSettings(BaseModel): + """Structured logging configuration schema.""" + + level: str = "INFO" + json_format: bool | None = None + log_sql_queries: bool = True + slow_query_threshold_ms: float | None = None + file_path: str | None = None + muted_loggers: list[str] = Field( + default_factory=lambda: [ + "uvicorn", + "uvicorn.access", + "uvicorn.error", + "sqlalchemy.engine", + ] + ) + custom_processors: list[Any] = Field(default_factory=list) + + class Settings(BaseSettings): """Core settings and environment variables configuration for the ZCore framework. This class parses configuration variables from both environment variables and optional file-based sources (such as a `.env` file). It manages configuration for - the database engine, authentication parameters, file storage paths, and other core services. + the database engine, logging subsystems, authentication parameters, file storage paths, + timezone policies, and other core services. Attributes: + DATABASE: Structured configuration model for database engine settings. DATABASE_URL: Connection URI for the primary relational database. MAX_OVERFLOW: Maximum number of connections allowed beyond the database pool size. POOL_SIZE: The connection pool size for database connections. DATABASE_TEST_URL: Connection URI for database testing and integration runs. + LOGGING: Structured configuration model for framework logging settings. + LOG_LEVEL: Fallback environment logging level string. + TIMEZONE: IANA standard timezone string used across the application. + AUTO_CONVERT_TIMEZONE: Boolean flag determining automatic API timezone conversions. SECRET_KEY: Cryptographic secret key used for signing web tokens and hashes. PROJECT_NAME: Name of the project. ALGORITHM: Cryptographic algorithm utilized for signing JWTs. ACCESS_TOKEN_EXPIRE_MINUTES: Expiry duration for authentication access tokens in minutes. REFRESH_TOKEN_EXPIRE_DAYS: Expiry duration for refresh tokens in days. STORAGE_PATH: Local filesystem base path reserved for target storage uploads. + STORAGE_URL_PREFIX: HTTP URL prefix mapped to exposed static assets. REDIS_URL: Redis connection URI, or None if Redis is not used. DEBUG: Boolean flag indicating whether the application is in debug mode. """ @@ -43,30 +84,63 @@ class Settings(BaseSettings): env_file=os.getenv("ENV_FILE", ".env"), extra="ignore", case_sensitive=True ) + DATABASE: DatabaseSettings = Field(default_factory=DatabaseSettings) DATABASE_URL: str = "sqlite+aiosqlite:///zcore.db" MAX_OVERFLOW: int = 10 POOL_SIZE: int = 5 DATABASE_TEST_URL: str = "sqlite+aiosqlite:///zcore_test.db" + LOGGING: LoggingSettings = Field(default_factory=LoggingSettings) + LOG_LEVEL: str = "INFO" + + TIMEZONE: str = "UTC" + AUTO_CONVERT_TIMEZONE: bool = True + SECRET_KEY: str = "zcore-insecure-fallback-secret-key-must-be-changed" PROJECT_NAME: str = "ZCore Application" ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 REFRESH_TOKEN_EXPIRE_DAYS: int = 7 - STORAGE_PATH: str = "./storage" + STORAGE_PATH: str = Field( + default="./storage", + validation_alias=AliasChoices("STORAGE_PATH", "STORAGE_BASE_PATH"), + ) + STORAGE_URL_PREFIX: str = Field( + default="/storage", + validation_alias=AliasChoices("STORAGE_URL_PREFIX", "STORAGE_PREFIX"), + ) REDIS_URL: str | None = None DEBUG: bool = True + @model_validator(mode="after") + def _sync_settings(self) -> "Settings": + if self.DATABASE_URL != "sqlite+aiosqlite:///zcore.db" and self.DATABASE.url == "sqlite+aiosqlite:///zcore.db": + self.DATABASE.url = self.DATABASE_URL + elif self.DATABASE.url != "sqlite+aiosqlite:///zcore.db" and self.DATABASE_URL == "sqlite+aiosqlite:///zcore.db": + self.DATABASE_URL = self.DATABASE.url + + if self.POOL_SIZE != 5 and self.DATABASE.pool_size == 5: + self.DATABASE.pool_size = self.POOL_SIZE + elif self.DATABASE.pool_size != 5 and self.POOL_SIZE == 5: + self.POOL_SIZE = self.DATABASE.pool_size + + if self.MAX_OVERFLOW != 10 and self.DATABASE.max_overflow == 10: + self.DATABASE.max_overflow = self.MAX_OVERFLOW + elif self.DATABASE.max_overflow != 10 and self.MAX_OVERFLOW == 10: + self.MAX_OVERFLOW = self.DATABASE.max_overflow + + if self.LOG_LEVEL != "INFO" and self.LOGGING.level == "INFO": + self.LOGGING.level = self.LOG_LEVEL + elif self.LOGGING.level != "INFO" and self.LOG_LEVEL == "INFO": + self.LOG_LEVEL = self.LOGGING.level + + return self + def initialize_settings(settings_inst: Settings) -> None: """Register the settings instance in the IoC dependency injection container. - This function binds the instantiated settings class to the DI container. If the - provided instance is a subclass of Settings, it registers both the specific - subclass and the base Settings type, allowing downstream components to - inject the base class or the custom subclass seamlessly. - Args: settings_inst: An instance of `Settings` (or its subclasses) to register into the global container. @@ -79,9 +153,6 @@ def initialize_settings(settings_inst: Settings) -> None: def get_settings(settings_class: type[T] = Settings) -> T: """Retrieve the settings instance from the dependency injection container. - If the specified settings class has not yet been registered in the DI container, - this function instantiates it, registers it as a singleton, and then returns it. - Args: settings_class: The class type of the settings to resolve. Defaults to Settings. @@ -98,28 +169,10 @@ def get_settings(settings_class: type[T] = Settings) -> T: class SettingsProxy: - """Proxy object providing lazy attribute access to the active settings instance. - - This proxy allows developers to import a global `settings` object without triggering - premature initialization of the dependency injection container or settings configuration - lookup during import time. Configuration lookups are dynamically resolved against the - active registered settings instance on demand. - """ + """Proxy object providing lazy attribute access to the active settings instance.""" def __getattr__(self, name: str) -> Any: - """Dynamically retrieve configuration values from the active settings instance. - - Args: - name: The attribute name of the configuration option to fetch. - - Returns: - The value associated with the specified attribute name. - - Raises: - AttributeError: If the resolved settings instance does not contain - the requested attribute. - """ return getattr(get_settings(), name) -settings = SettingsProxy() +settings = SettingsProxy() \ No newline at end of file diff --git a/src/zcore/db/pagination.py b/src/zcore/db/pagination.py index 86206dc..d2fcd25 100644 --- a/src/zcore/db/pagination.py +++ b/src/zcore/db/pagination.py @@ -31,8 +31,7 @@ class PaginatedResult(Generic[T]): Attributes: data: The segment sequence of retrieved database entities. - meta: A dictionary containing structural page metadata (e.g., total pages, - cursors, sizes, limits). + meta: A dictionary containing structural page metadata. """ def __init__(self, data: Sequence[T], meta: dict[str, Any]): @@ -324,4 +323,4 @@ async def paginate( next_cursor = self._encode_cursor(items[-1]) meta = {"next_cursor": next_cursor, "has_more": has_more, "size": size} - return PaginatedResult(data=items, meta=meta) + return PaginatedResult(data=items, meta=meta) \ No newline at end of file diff --git a/src/zcore/db/repository.py b/src/zcore/db/repository.py index 8e160d5..be45ed4 100644 --- a/src/zcore/db/repository.py +++ b/src/zcore/db/repository.py @@ -166,19 +166,19 @@ async def get_by_ids( async def get_list( self, + *criterion: Any, pagination: Any = None, fields: list[Any] | None = None, options: list[ExecutableOption] | None = None, - *criterion: Any, **filters: Any, ) -> Any: """Fetch a paginated or complete list of records matching filters. Args: + *criterion: Positional binary SQLAlchemy filter expressions. pagination: Pagination parameters. Defaults to None. fields: Specific entity fields to selectively load. Defaults to None. options: Additional SQLAlchemy execution options. Defaults to None. - *criterion: Positional binary SQLAlchemy filter expressions. **filters: Keyword key-value arguments for standard equality filters. Returns: @@ -248,7 +248,7 @@ async def create(self, schema: BaseModel, **extra_data: Any) -> ModelType: async def create_multi( self, schemas: list[BaseModel], refresh: bool = False ) -> Sequence[ModelType]: - """Create multiple database records from a list of validation schemas. + """Create multiple database records with dialect-aware fallback for returning support. Args: schemas: A list of Pydantic schemas representing the new database objects. @@ -262,18 +262,34 @@ async def create_multi( return [] payloads = [schema.model_dump() for schema in schemas] - stmt = insert(self.model).values(payloads).returning(self.model) - result = await self.db.execute(stmt) - await self.db.flush() - return list(result.scalars().all()) + dialect = getattr(getattr(self.db, "bind", None), "dialect", None) + supports_returning = bool(getattr(dialect, "insert_returning", False)) + + if supports_returning: + stmt = insert(self.model).values(payloads).returning(self.model) + result = await self.db.execute(stmt) + await self.db.flush() + records = list(result.scalars().all()) + if refresh: + for r in records: + await self.db.refresh(r) + return records + else: + records = [self.model(**p) for p in payloads] + self.db.add_all(records) + await self.db.flush() + if refresh: + for r in records: + await self.db.refresh(r) + return records async def update( - self, id: Any, schema: BaseModel, partial: bool = False, **extra_data: Any + self, target: ModelType | Any, schema: BaseModel, partial: bool = False, **extra_data: Any, ) -> ModelType | None: - """Update an existing database record with dynamic fields. + """Update an existing database record from a model instance or primary key. Args: - id: The primary key identifier of the record to update. + target: The model instance or primary key identifier of the record to update. schema: The Pydantic update schema containing modified parameters. partial: If True, applies modifications as a partial patch (ignoring unset fields). If False, updates the record using all fields. Defaults to False. @@ -282,9 +298,13 @@ async def update( Returns: The updated and refreshed database model instance, or None if the record was not found. """ - record = await self.get(**{self.pk_name: id}) - if not record: - return None + if isinstance(target, self.model): + record = target + else: + record = await self.get(**{self.pk_name: target}) + if not record: + return None + update_data = schema.model_dump(exclude_unset=partial) update_data.update(extra_data) for field, value in update_data.items(): @@ -294,32 +314,32 @@ async def update( return record async def update_multi( - self, data: dict[Any, BaseModel], partial: bool = False, refresh: bool = False + self, data: dict[ModelType | Any, BaseModel], partial: bool = False, refresh: bool = False, ) -> Sequence[ModelType]: """Bulk update multiple database records using DBAPI executemany. - Executes the updates without hydrating ORM entities beforehand, then fetches - and returns the updated model instances in a single batch query. - Args: - data: A mapping of primary keys to their update schemas. + data: A mapping of model instances or primary keys to their update schemas. + partial: If True, ignores unset schema fields. Defaults to False. + refresh: Parameter maintained for interface consistency. Defaults to False. Returns: A sequence containing the updated database model instances. - - Raises: - sqlalchemy.orm.exc.StaleDataError: If any target primary key does not exist. """ if not data: return [] - payloads = [ - { - **schema.model_dump(exclude_unset=partial), - self.pk_name: pk_val, - } - for pk_val, schema in data.items() - ] + payloads = [] + target_ids = [] + for key, schema in data.items(): + pk_val = getattr(key, self.pk_name, key) + target_ids.append(pk_val) + payloads.append( + { + **schema.model_dump(exclude_unset=partial), + self.pk_name: pk_val, + } + ) await self.db.execute( update(self.model), @@ -327,26 +347,30 @@ async def update_multi( ) await self.db.flush() - return await self.get_by_ids(ids=list(data.keys())) + return await self.get_by_ids(ids=target_ids) - async def delete(self, id: Any) -> ModelType | None: - """Delete a single record by its primary key identifier. + async def delete(self, target: ModelType | Any) -> ModelType | None: + """Delete a single record by its model instance or primary key identifier. Args: - id: The primary key value of the target record to delete. + target: The model instance or primary key value of the target record to delete. Returns: The deleted database model instance, or None if the record was not found. """ - record = await self.get(**{self.pk_name: id}) - if not record: - return None + if isinstance(target, self.model): + record = target + else: + record = await self.get(**{self.pk_name: target}) + if not record: + return None + await self.db.delete(record) await self.db.flush() return record async def delete_multi(self, ids: list[Any]) -> Sequence[ModelType]: - """Delete multiple records matching the provided list of primary keys. + """Delete multiple records matching the provided list of primary keys with dialect-aware fallback. Args: ids: A list of primary key values of records to delete. @@ -357,10 +381,21 @@ async def delete_multi(self, ids: list[Any]) -> Sequence[ModelType]: if not ids: return [] - stmt = delete(self.model).where(self.pk.in_(ids)).returning(self.model) - result = await self.db.scalars(stmt) - await self.db.flush() - return list(result.all()) + dialect = getattr(getattr(self.db, "bind", None), "dialect", None) + supports_returning = bool(getattr(dialect, "delete_returning", False)) + + if supports_returning: + stmt = delete(self.model).where(self.pk.in_(ids)).returning(self.model) + result = await self.db.scalars(stmt) + await self.db.flush() + return list(result.all()) + else: + records = list(await self.get_by_ids(ids=ids)) + if records: + stmt = delete(self.model).where(self.pk.in_(ids)) + await self.db.execute(stmt) + await self.db.flush() + return records class SearchRepositoryMixin(AbstractRepository[ModelType]): @@ -414,4 +449,4 @@ def __init__(self, model: type[ModelType], db: AsyncSession): self.db = db self.pk = inspect(self.model).primary_key[0] self.pk_name = self.pk.name - self.cursor_field = self.pk_name + self.cursor_field = self.pk_name \ No newline at end of file diff --git a/src/zcore/db/search.py b/src/zcore/db/search.py index b273edf..efce6b2 100644 --- a/src/zcore/db/search.py +++ b/src/zcore/db/search.py @@ -3,7 +3,7 @@ This module provides a dynamic query-builder that parses client-supplied filters, sorting criteria, and relation-preload requests. Crucially, the system coordinates with security context restrictions, intercepting and blocking queries that target -unauthorized fields or database relations. +unauthorized fields or database relations, and safely handles type conversions. """ from __future__ import annotations @@ -14,7 +14,7 @@ from typing import Any, Literal, TypeVar from pydantic import BaseModel, Field -from sqlalchemy import and_, asc, desc, inspect, or_, select +from sqlalchemy import String, and_, asc, cast, desc, inspect, or_, select from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.sql import Select @@ -94,6 +94,7 @@ class SearchEngine: model: The root database model bound to this search instance. mapper: The SQLAlchemy mapper interface for the model. custom_handlers: Custom translation callbacks registered for specific fields. + max_depth: Maximum allowable depth for relational query expansions. """ def __init__(self, model: type[ModelType]): @@ -374,16 +375,25 @@ def _coerce_value(self, col: Any, value: Any) -> Any: Returns: The coerced python value. + + Raises: + ValidationError: If type conversion fails due to malformed input values. """ if value is None: return value - if isinstance(value, (list, tuple)): + if isinstance(value, (list, tuple, set)): return [self._coerce_value(col, v) for v in value] try: - python_type = col.type.python_type + python_type = getattr(col.type, "python_type", None) + except (NotImplementedError, AttributeError): + python_type = None + if python_type is None: + return value + + try: if python_type is date and isinstance(value, str): return date.fromisoformat(value.split("T")[0]) @@ -396,50 +406,70 @@ def _coerce_value(self, col: Any, value: Any) -> Any: if python_type is bool and isinstance(value, str): return value.lower() in ("true", "1", "yes", "t", "y") - except (NotImplementedError, AttributeError, ValueError, TypeError): - pass + if python_type in (int, float) and isinstance(value, str): + return python_type(value) + + except (ValueError, TypeError) as e: + col_name = getattr(col, "key", str(col)) + raise ValidationError( + message=f"Invalid value '{value}' for column '{col_name}': failed to coerce to {python_type.__name__}." + ) from e return value def _compare_column(self, col: Any, op: str, value: Any) -> Any: - """Build database comparison clauses matching standard filter operators. + """Build database comparison clauses matching standard filter operators with type safety. Args: col: The active comparison column. op: The filter comparison operator string. - value: The coerced parameter value. + value: The parameter value. Returns: The generated comparison clause, or None if unsupported. + + Raises: + ValidationError: If operator constraints or value structures are violated. """ if op == "is_null": return col.is_(None) if value else col.isnot(None) - if op == "ilike": - escaped_value = self._escape_like_wildcards(value) - return col.ilike(f"%{escaped_value}%", escape="\\") - - if op == "startswith": - escaped_value = self._escape_like_wildcards(value) - return col.ilike(f"{escaped_value}%", escape="\\") - - if op == "endswith": - escaped_value = self._escape_like_wildcards(value) - return col.ilike(f"%{escaped_value}", escape="\\") - - if op == "contains": - if hasattr(col.type, "python_type") and col.type.python_type is str: - escaped_value = self._escape_like_wildcards(value) - return col.ilike(f"%{escaped_value}%", escape="\\") - return col.contains(value) + if op in ("ilike", "startswith", "endswith", "contains"): + escaped_value = self._escape_like_wildcards( + str(value) if value is not None else "" + ) + try: + is_str = ( + hasattr(col.type, "python_type") and col.type.python_type is str + ) + except (NotImplementedError, AttributeError): + is_str = False + + target_col = col if is_str else cast(col, String) + + if op in ("ilike", "contains"): + return target_col.ilike(f"%{escaped_value}%", escape="\\") + if op == "startswith": + return target_col.ilike(f"{escaped_value}%", escape="\\") + if op == "endswith": + return target_col.ilike(f"%{escaped_value}", escape="\\") if op == "between": if not isinstance(value, (list, tuple)) or len(value) != 2: - raise ValidationError(message=f"Operator 'between' expects a list of 2 elements, got {value}") + col_name = getattr(col, "key", str(col)) + raise ValidationError( + message=f"Operator 'between' requires a list of exactly 2 items for column '{col_name}', received: {value}" + ) lower = self._coerce_value(col, value[0]) upper = self._coerce_value(col, value[1]) return col.between(lower, upper) + if op == "in": + if not isinstance(value, (list, tuple, set)): + value = [value] + coerced_list = [self._coerce_value(col, v) for v in value] + return col.in_(coerced_list) + coerced_value = self._coerce_value(col, value) if op == "eq": @@ -455,13 +485,6 @@ def _compare_column(self, col: Any, op: str, value: Any) -> Any: if op == "le": return col <= coerced_value - if op == "in": - return col.in_( - coerced_value - if isinstance(coerced_value, (list, tuple)) - else [coerced_value] - ) - return None def _apply_includes(self, query: Select, include_paths: list[str]) -> Select: @@ -546,4 +569,4 @@ def build_query(self, search_in: SearchRequest) -> Select: """ query = self.build_base_query(search_in) offset = (search_in.page - 1) * search_in.size - return query.offset(offset).limit(search_in.size) + return query.offset(offset).limit(search_in.size) \ No newline at end of file diff --git a/src/zcore/db/setup.py b/src/zcore/db/setup.py index 52a2892..3be1078 100644 --- a/src/zcore/db/setup.py +++ b/src/zcore/db/setup.py @@ -23,7 +23,9 @@ ) from sqlalchemy.orm import DeclarativeBase +from zcore.config import settings from zcore.kernel.di import container +from zcore.utils.helpers import json_dumps, json_loads @dataclass(frozen=True) @@ -106,7 +108,11 @@ def __init__(self) -> None: self._session_factory: async_sessionmaker[AsyncSession] | None = None def _register_query_logger(self, sync_engine: Engine) -> None: - """Register events on connection cursors to intercept, sanitize, and log query statistics.""" + """Register events on connection cursors to intercept, sanitize, and log query statistics. + + Args: + sync_engine: The synchronous core SQLAlchemy Engine instance. + """ dialect_name = sync_engine.dialect.name db_logger = structlog.get_logger(f"zcore.db.{dialect_name}") @@ -120,14 +126,25 @@ def before_cursor_execute( def after_cursor_execute( conn, cursor, statement, parameters, context, exec_many ): + logging_cfg = getattr(settings, "LOGGING", None) + if logging_cfg and not getattr(logging_cfg, "log_sql_queries", True): + return + start_time = getattr(context, "_query_start_time", None) duration_ms = 0.0 if start_time: duration_ms = (time.perf_counter() - start_time) * 1000 + slow_threshold = ( + getattr(logging_cfg, "slow_query_threshold_ms", None) + if logging_cfg + else None + ) + if slow_threshold is not None and duration_ms < slow_threshold: + return + compact_statement = " ".join(statement.split()) - # Bypass low-level database metadata or catalog lookups to avoid noise ignored_keywords = [ "pg_catalog", "schema", @@ -146,47 +163,109 @@ def after_cursor_execute( def init_app( self, - db_url: str, + db_url: str | None = None, + config: Any | None = None, pool_size: int = 5, max_overflow: int = 10, pool_recycle: int = 1800, + pool_pre_ping: bool = True, echo: bool = False, + connect_args: dict[str, Any] | None = None, + execution_options: dict[str, Any] | None = None, **engine_kwargs: Any, ) -> None: """Configure the connection pool, engine, and session factories. Configures parameters for SQLite and server-based relational engines - (such as PostgreSQL). + (such as PostgreSQL or MySQL), supporting structured settings, custom + connect_args, execution_options, and JSON serializers. Args: - db_url: The primary database connection URL. + db_url: The primary database connection URL. Defaults to None. + config: An optional `DatabaseSettings` instance or configuration dictionary. + Defaults to None. pool_size: The connection pool size for non-SQLite databases. Defaults to 5. max_overflow: The max overflowing connections beyond pool size. Defaults to 10. pool_recycle: Connection recycle time in seconds. Defaults to 1800. + pool_pre_ping: Boolean flag to enable pool pre-ping connection checks. Defaults to True. echo: Verbose SQL logging flag. Unused directly, replaced by interceptor. + connect_args: Connection arguments passed directly to the DBAPI driver. + execution_options: Execution options passed to the SQLAlchemy engine. **engine_kwargs: Additional keyword arguments forwarded to `create_async_engine`. + + Raises: + ValueError: If no valid database URL is provided directly or via config. """ - kwargs: dict[str, Any] = {} - if "sqlite" not in db_url: - kwargs["pool_size"] = pool_size - kwargs["max_overflow"] = max_overflow - kwargs["pool_recycle"] = pool_recycle + target_url = db_url + target_pool_size = pool_size + target_max_overflow = max_overflow + target_pool_recycle = pool_recycle + target_pool_pre_ping = pool_pre_ping + target_connect_args = connect_args or {} + target_execution_options = execution_options or {} + extra_kwargs = dict(engine_kwargs) + + if config is not None: + if hasattr(config, "model_dump"): + cfg_dict = config.model_dump() + elif isinstance(config, dict): + cfg_dict = dict(config) + else: + cfg_dict = {} + + target_url = cfg_dict.get("url", target_url) + target_pool_size = cfg_dict.get("pool_size", target_pool_size) + target_max_overflow = cfg_dict.get("max_overflow", target_max_overflow) + target_pool_recycle = cfg_dict.get("pool_recycle", target_pool_recycle) + target_pool_pre_ping = cfg_dict.get("pool_pre_ping", target_pool_pre_ping) + + cfg_connect_args = cfg_dict.get("connect_args") + if isinstance(cfg_connect_args, dict): + target_connect_args = {**target_connect_args, **cfg_connect_args} + + cfg_execution_options = cfg_dict.get("execution_options") + if isinstance(cfg_execution_options, dict): + target_execution_options = { + **target_execution_options, + **cfg_execution_options, + } + + cfg_extra = cfg_dict.get("extra_engine_kwargs") + if isinstance(cfg_extra, dict): + extra_kwargs.update(cfg_extra) + + if not target_url: + raise ValueError("A valid database URL must be provided to init_app.") + + final_engine_kwargs: dict[str, Any] = { + "echo": False, + "json_serializer": json_dumps, + "json_deserializer": json_loads, + "pool_pre_ping": target_pool_pre_ping, + **extra_kwargs, + } + + if "sqlite" not in target_url.lower(): + final_engine_kwargs["pool_size"] = target_pool_size + final_engine_kwargs["max_overflow"] = target_max_overflow + final_engine_kwargs["pool_recycle"] = target_pool_recycle + + if target_connect_args: + final_engine_kwargs["connect_args"] = target_connect_args + + if target_execution_options: + final_engine_kwargs["execution_options"] = target_execution_options self._engine = create_async_engine( - db_url, - echo=False, # Core SQLAlchemy raw echo is disabled to prevent logger double-cluttering - pool_pre_ping=True, - **kwargs, - **engine_kwargs, + target_url, + **final_engine_kwargs, ) self._session_factory = async_sessionmaker( bind=self._engine, class_=AsyncSession, expire_on_commit=False ) - # Attach the query-timing event hook directly to the underlying sync engine pool self._register_query_logger(self._engine.sync_engine) - # Resolve dialect-specific structural logging namespace dialect_logger = structlog.get_logger(f"zcore.db.{self._engine.dialect.name}") dialect_logger.info( "DatabaseManager successfully initialized with dialect statement logger." @@ -240,4 +319,4 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]: yield session -SessionDep = Annotated[AsyncSession, Depends(get_db)] +SessionDep = Annotated[AsyncSession, Depends(get_db)] \ No newline at end of file diff --git a/src/zcore/db/soft_delete.py b/src/zcore/db/soft_delete.py index 8b978d4..dd9b97e 100644 --- a/src/zcore/db/soft_delete.py +++ b/src/zcore/db/soft_delete.py @@ -1,10 +1,20 @@ -from datetime import UTC, datetime +"""Soft Delete Mixin Module. + +This module provides the soft delete capability for SQLAlchemy declarative models, +managing record suppression and restoration using timezone-aware timestamps. +""" + +from datetime import datetime from sqlalchemy import DateTime, Select from sqlalchemy.orm import Mapped, mapped_column +from zcore.utils.timezone import now + class SoftDeleteMixin: + """Mixin providing soft-deletion capabilities to SQLAlchemy declarative models.""" + deleted_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), default=None, @@ -21,7 +31,7 @@ def scope_query(cls, query: Select) -> Select: return query.where(cls.deleted_at.is_(None)) def soft_delete(self) -> None: - self.deleted_at = datetime.now(UTC) + self.deleted_at = now() def restore(self) -> None: self.deleted_at = None \ No newline at end of file diff --git a/src/zcore/exceptions/__init__.py b/src/zcore/exceptions/__init__.py index 792659e..09e2857 100644 --- a/src/zcore/exceptions/__init__.py +++ b/src/zcore/exceptions/__init__.py @@ -1,3 +1,7 @@ +"""ZCore Application Exceptions Package.""" + +from typing import TYPE_CHECKING, Any + from zcore.exceptions.base import ( AppException, AuthError, @@ -6,7 +10,9 @@ ForbiddenError, ValidationError, ) -from zcore.exceptions.handlers import app_exception_handler + +if TYPE_CHECKING: + from zcore.exceptions.handlers import app_exception_handler __all__ = [ "AppException", @@ -17,3 +23,12 @@ "ValidationError", "app_exception_handler", ] + + +def __getattr__(name: str) -> Any: + """Lazily import exception handlers to prevent circular dependencies with the web layer.""" + if name == "app_exception_handler": + from zcore.exceptions.handlers import app_exception_handler + + return app_exception_handler + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") \ No newline at end of file diff --git a/src/zcore/kernel/di.py b/src/zcore/kernel/di.py index 480d3a1..4792d7b 100644 --- a/src/zcore/kernel/di.py +++ b/src/zcore/kernel/di.py @@ -2,8 +2,8 @@ This module implements custom Singleton, Scoped, and Transient injection strategies. It resolves types dynamically using constructor reflection, utilizing signature caching -to mitigate reflection runtime performance overhead, and implements protective mechanisms -against cyclic dependencies. +to mitigate reflection runtime performance overhead, implements protective mechanisms +against cyclic dependencies, and coordinates isolated background task execution scopes. """ import functools @@ -21,13 +21,14 @@ get_type_hints, ) +import structlog from anyio import to_thread from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession +logger = structlog.get_logger() T = TypeVar("T") -# Safe context storage for active scoped instances _current_scope_id: ContextVar[str | None] = ContextVar("scope_id", default=None) _scoped_instances: ContextVar[dict[type[Any], Any]] = ContextVar( "scoped_instances", default={} @@ -58,8 +59,7 @@ class IoCContainer: _scoped_definitions: Mappings of interfaces to factory functions bound to request contexts. _factories: Mappings of interfaces to factory functions for transient lifecycles. _constructor_cache: Cache storing raw init constructors of target classes. - _dependency_signature_cache: Cache storing evaluated constructor dependencies - of targets. + _dependency_signature_cache: Cache storing evaluated constructor dependencies of targets. """ def __init__(self) -> None: @@ -68,7 +68,6 @@ def __init__(self) -> None: self._scoped_definitions: dict[type[Any], Callable[..., Any]] = {} self._factories: dict[type[Any], Callable[..., Any]] = {} - # High-performance caching for resolved type hints and constructors self._constructor_cache: dict[type[Any], Callable[..., Any] | None] = {} self._dependency_signature_cache: dict[type[Any], list[type[Any]]] = {} @@ -191,7 +190,6 @@ def _auto_wire( if not inspect.isclass(target_class): return target_class - # Initialize recursion verification stack _stack = _stack or set() if target_class in _stack: chain = ( @@ -203,7 +201,6 @@ def _auto_wire( _stack.add(target_class) try: - # High-Performance Signature Caching Lookup if target_class in self._dependency_signature_cache: dependencies = self._dependency_signature_cache[target_class] resolved_args = [ @@ -211,7 +208,6 @@ def _auto_wire( ] return target_class(*resolved_args) - # Retrieve constructor safely if target_class not in self._constructor_cache: constructor = getattr(target_class, "__init__", None) self._constructor_cache[target_class] = constructor @@ -222,7 +218,6 @@ def _auto_wire( self._dependency_signature_cache[target_class] = [] return target_class() - # Using typing.get_type_hints to resolve forward references try: type_hints = get_type_hints(constructor) except Exception: @@ -234,7 +229,6 @@ def _auto_wire( for name, param in sig.parameters.items(): if name == "self": continue - # Resolve the annotation type, checking get_type_hints first for forward references annotation = type_hints.get(name, param.annotation) if annotation is inspect.Parameter.empty: continue @@ -244,7 +238,6 @@ def _auto_wire( dependencies.append(annotation) - # Warm cache for subsequent requests (reduces reflection overhead) self._dependency_signature_cache[target_class] = dependencies resolved_args = [self.resolve(dep, _stack.copy()) for dep in dependencies] @@ -256,8 +249,6 @@ def _auto_wire( def clear_scope(self, scope_id: str) -> None: """Explicit scope cleanup hook. - Maintains structural compatibility with scoped lifecycle triggers. - Args: scope_id: The string identifier of the scope to purge. """ @@ -294,13 +285,11 @@ async def __call__(self) -> Any: class Inject: """Dynamic type marker supporting unified Annotated dependency injection. - Allows elegant type annotations in FastAPI routers, e.g., `service: Inject[UserService]`. - Under the hood, this converts the parameter metadata using `Annotated` and - binds it to the IoC container's resolver. + Allows type annotations in FastAPI routers, e.g., `service: Inject[UserService]`. """ def __class_getitem__(cls, interface: type[T]) -> Any: - """Map the class generic bracket access to an Annotated dependency representation. + """Map generic bracket access to an Annotated dependency representation. Args: interface: The target type interface dependency to resolve. @@ -319,13 +308,12 @@ async def background_scope( """Provide an isolated IoC, Database Session, and ZContext scope for background tasks. Ensures that asynchronous background routines execute within an independent transaction - boundary and IoC scope without relying on or interfering with completed HTTP request lifecycles. + boundary and IoC scope without relying on completed HTTP request lifecycles. Automatically manages database session allocation, context variable isolation, and guaranteed resource disposal upon exit. Args: - inherit_context: If True, clones active request context parameters (e.g., user_id, scopes) - into the background scope. Defaults to True. + inherit_context: If True, clones active request context parameters into the background scope. **custom_context: Explicit key-value pairs to set or override in the background context store. Yields: @@ -355,8 +343,8 @@ def background_task(func: Callable[..., Any]) -> Callable[..., Any]: """Decorator that wraps a background task with an isolated scope and auto-resolves dependencies. Inspects the wrapped function's signature and automatically resolves any unprovided, - type-annotated parameters directly from the global IoC container. Supports both asynchronous - coroutines and standard synchronous functions (executed asynchronously in a worker threadpool). + type-annotated parameters directly from the global IoC container within a dedicated background scope. + Supports both asynchronous coroutines and standard synchronous functions. Args: func: The target synchronous or asynchronous callable to execute in the background. @@ -366,6 +354,20 @@ def background_task(func: Callable[..., Any]) -> Callable[..., Any]: """ is_coroutine = inspect.iscoroutinefunction(func) + def _warn_if_closed_session_passed(args: tuple[Any, ...], kwargs: dict[str, Any]) -> None: + for arg in args: + if isinstance(arg, AsyncSession): + logger.warning( + "Passing an existing AsyncSession directly to a background task is discouraged. " + "Background tasks should allow @background_task to inject an isolated session automatically." + ) + for k, v in kwargs.items(): + if isinstance(v, AsyncSession): + logger.warning( + f"Passing an existing AsyncSession directly via argument '{k}' to a background task is discouraged. " + "Background tasks should allow @background_task to inject an isolated session automatically." + ) + def _resolve_injections(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]: sig = inspect.signature(func) bound_args = sig.bind_partial(*args, **kwargs) @@ -387,16 +389,20 @@ def _resolve_injections(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[s return resolved_kwargs if is_coroutine: + @functools.wraps(func) async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + _warn_if_closed_session_passed(args, kwargs) async with background_scope(): final_kwargs = _resolve_injections(args, kwargs) return await func(*args, **final_kwargs) return async_wrapper else: + @functools.wraps(func) async def sync_wrapper(*args: Any, **kwargs: Any) -> Any: + _warn_if_closed_session_passed(args, kwargs) async with background_scope(): final_kwargs = _resolve_injections(args, kwargs) return await to_thread.run_sync( diff --git a/src/zcore/kernel/engine.py b/src/zcore/kernel/engine.py index a91b3f1..1b26e02 100644 --- a/src/zcore/kernel/engine.py +++ b/src/zcore/kernel/engine.py @@ -2,7 +2,7 @@ This module coordinates application modules, managing registration, dependency resolution, and topological sorting of Plugins. It orchestrates async startup and shutdown Lifespan hooks -associated with FastAPI applications. +associated with FastAPI applications, ensuring guaranteed resource disposal upon termination. """ import graphlib @@ -97,7 +97,8 @@ async def lifespan(self, app: FastAPI) -> AsyncGenerator[None, None]: """Asynchronous context manager governing the active application lifespan. Sequentially invokes startup lifecycle phases on sorted plugin units - and guarantees clean, reversed tear-down triggers upon termination. + and guarantees clean, reversed tear-down triggers upon termination, + including automatic database and cache connection disposal. Args: app: The active running FastAPI instance. @@ -122,3 +123,9 @@ async def lifespan(self, app: FastAPI) -> AsyncGenerator[None, None]: finally: for plugin in reversed(self._sorted_plugins): await plugin.on_shutdown() + + from zcore.cache.base import close_cache + from zcore.db.setup import db_manager + + await close_cache() + await db_manager.close() \ No newline at end of file diff --git a/src/zcore/logging/config.py b/src/zcore/logging/config.py index 2037050..55cce0f 100644 --- a/src/zcore/logging/config.py +++ b/src/zcore/logging/config.py @@ -1,38 +1,86 @@ """ZCore Structured Logging Configuration. This module initializes the application's logging pipeline. It integrates standard -Python `logging` with `structlog` to provide structured, context-aware diagnostics. -It dynamically formats output as developer-friendly, colorized text on interactive terminals -or serialized JSON records on production stream targets. +Python `logging` with `structlog` via ProcessorFormatter to provide a multi-tier, +enterprise-ready logging subsystem supporting custom handlers, file rotation, and full dictConfig overrides. """ import logging +import logging.config +import sys +from typing import Any import structlog -from zcore.config import settings +from zcore.config import LoggingSettings, settings -def setup_logging() -> None: - """Configure the global structlog logging engine. +def setup_logging( + config: LoggingSettings | dict[str, Any] | None = None, + custom_processors: list[Any] | None = None, + extra_handlers: list[logging.Handler] | None = None, + dict_config: dict[str, Any] | None = None, + log_level: int | str | None = None, +) -> None: + """Configure the global structlog and standard logging engine. - Suppresses duplicate logging handlers from third-party libraries (like uvicorn or sqlalchemy) - and routes all framework telemetry through a unified structlog pipeline. + Supports zero-code declarative settings from `.env`, code-level handler and processor injections, + and complete `logging.config.dictConfig` overrides. + + Args: + config: An optional `LoggingSettings` instance or dictionary configuration. + custom_processors: Optional list of additional structlog processors to include. + extra_handlers: Optional list of Python `logging.Handler` instances to attach. + dict_config: Optional full dictionary passed directly to `logging.config.dictConfig`. + log_level: Optional explicit log level override. """ - for logger_name in ("uvicorn", "uvicorn.access", "sqlalchemy.engine"): - log = logging.getLogger(logger_name) - log.handlers = [] - log.propagate = True + if dict_config is not None: + logging.config.dictConfig(dict_config) + return + + if config is not None: + if isinstance(config, dict): + cfg = LoggingSettings.model_validate(config) + elif isinstance(config, LoggingSettings): + cfg = config + else: + cfg = getattr(settings, "LOGGING", LoggingSettings()) + else: + cfg = getattr(settings, "LOGGING", LoggingSettings()) + + if log_level is not None: + if isinstance(log_level, str): + level = getattr(logging, log_level.upper(), logging.INFO) + else: + level = log_level + else: + cfg_level_str = getattr(cfg, "level", "INFO") + level = getattr(logging, cfg_level_str.upper(), logging.INFO) shared_processors = [ structlog.contextvars.merge_contextvars, - structlog.processors.add_log_level, - structlog.processors.format_exc_info, - structlog.processors.TimeStamper(fmt="iso"), + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, ] - if getattr(settings, "DEBUG", False): + if cfg.custom_processors: + shared_processors.extend(cfg.custom_processors) + if custom_processors: + shared_processors.extend(custom_processors) + + is_json = ( + cfg.json_format + if cfg.json_format is not None + else not getattr(settings, "DEBUG", False) + ) + + if is_json: + renderer = structlog.processors.JSONRenderer() + else: try: from rich.traceback import install @@ -40,14 +88,57 @@ def setup_logging() -> None: except ImportError: pass renderer = structlog.dev.ConsoleRenderer(colors=True) - log_level = logging.DEBUG - else: - renderer = structlog.processors.JSONRenderer() - log_level = logging.INFO + + formatter = structlog.stdlib.ProcessorFormatter( + foreign_pre_chain=shared_processors, + processors=[ + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + renderer, + ], + ) + + stdout_handler = logging.StreamHandler(sys.stdout) + stdout_handler.setFormatter(formatter) + stdout_handler.setLevel(level) + + handlers: list[logging.Handler] = [stdout_handler] + + if cfg.file_path: + from logging.handlers import RotatingFileHandler + + file_handler = RotatingFileHandler( + cfg.file_path, + maxBytes=10 * 1024 * 1024, + backupCount=5, + encoding="utf-8", + ) + file_handler.setFormatter(formatter) + file_handler.setLevel(level) + handlers.append(file_handler) + + if extra_handlers: + for h in extra_handlers: + if not h.formatter: + h.setFormatter(formatter) + handlers.append(h) + + root_logger = logging.getLogger() + root_logger.handlers.clear() + for h in handlers: + root_logger.addHandler(h) + root_logger.setLevel(level) + + for logger_name in cfg.muted_loggers: + log = logging.getLogger(logger_name) + log.handlers.clear() + log.propagate = True structlog.configure( - processors=[*shared_processors, renderer], - wrapper_class=structlog.make_filtering_bound_logger(log_level), + processors=[ + *shared_processors, + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, cache_logger_on_first_use=True, - ) + ) \ No newline at end of file diff --git a/src/zcore/service/base.py b/src/zcore/service/base.py index 8402b66..2a6bef8 100644 --- a/src/zcore/service/base.py +++ b/src/zcore/service/base.py @@ -132,15 +132,26 @@ async def get_by_ids( async def get_list( self, + *criterion: Any, pagination: Any = None, fields: list[Any] | None = None, options: list[ExecutableOption] | None = None, - *criterion: Any, **filters: Any, ) -> Any: - """Fetch a paginated or complete listing of entities, applying post-retrieval hooks.""" + """Fetch a paginated or complete listing of entities, applying post-retrieval hooks. + + Args: + *criterion: Positional binary SQLAlchemy filter expressions. + pagination: Pagination parameters. Defaults to None. + fields: Selective list of model fields to return. Defaults to None. + options: Additional execution options. Defaults to None. + **filters: Dynamic keyword filters. + + Returns: + A list of matching records, or a paginated response container. + """ result = await self.repository.get_list( - pagination, fields, options, *criterion, **filters + *criterion, pagination=pagination, fields=fields, options=options, **filters ) if pagination is None: return await self.post_get_multi(result) @@ -176,9 +187,14 @@ async def post_create_multi(self, models: Sequence[ModelType]) -> None: pass async def pre_update( - self, id: Any, schema: BaseModel, partial: bool + self, target: ModelType | Any, schema: BaseModel, partial: bool ) -> dict[str, Any] | None: - """Hook triggered prior to modifying a single record. + """Hook triggered prior to modifying a record. + + Args: + target: The model instance or primary key of the record to update. + schema: The validated update schema. + partial: Boolean indicating whether unset fields are excluded. Returns: An optional dictionary containing values to merge with the update payload. @@ -189,7 +205,9 @@ async def post_update(self, model: ModelType) -> None: """Hook triggered after modifying a single record.""" pass - async def pre_update_multi(self, data: dict[Any, BaseModel], partial: bool) -> None: + async def pre_update_multi( + self, data: dict[ModelType | Any, BaseModel], partial: bool + ) -> None: """Hook triggered prior to updating a batch of records.""" pass @@ -234,20 +252,27 @@ async def on_create_multi( return await self.repository.create_multi(schemas, refresh=refresh) async def on_update( - self, id: Any, schema: BaseModel, partial: bool = False, **extra_data: Any + self, + target: ModelType | Any, + schema: BaseModel, + partial: bool = False, + **extra_data: Any, ) -> ModelType | None: """Execute core single-record update in database.""" - return await self.repository.update(id, schema, partial, **extra_data) + return await self.repository.update(target, schema, partial, **extra_data) async def on_update_multi( - self, data: dict[Any, BaseModel], partial: bool = False, refresh: bool = False + self, + data: dict[ModelType | Any, BaseModel], + partial: bool = False, + refresh: bool = False, ) -> Sequence[ModelType]: """Execute core batch-record updates in database.""" return await self.repository.update_multi(data, partial, refresh=refresh) - async def on_delete(self, id: Any) -> ModelType | None: + async def on_delete(self, target: ModelType | Any) -> ModelType | None: """Execute core single-record deletion in database.""" - return await self.repository.delete(id) + return await self.repository.delete(target) async def on_delete_multi(self, ids: list[Any]) -> Sequence[ModelType]: """Execute core batch-record deletions in database.""" @@ -283,14 +308,16 @@ async def create_multi( return result async def update( - self, id: Any, schema: BaseModel, partial: bool = False, **extra_data: Any + self, + target: ModelType | Any, + schema: BaseModel, + partial: bool = False, + **extra_data: Any, ) -> ModelType: - """Orchestrate modifications to an existing domain entity. - - Merges pre-update dictionary output with the update payload. + """Orchestrate modifications to an existing domain entity or instance. Args: - id: The primary key of the record to update. + target: The model instance or primary key of the record to update. schema: Validated fields representing the modifications. partial: If True, applies changes as a partial patch. Defaults to False. **extra_data: Extra fields to append. @@ -301,9 +328,9 @@ async def update( Raises: EntityNotFound: If the target entity identifier is not found in the database. """ - hook_data = await self.pre_update(id, schema, partial) or {} + hook_data = await self.pre_update(target, schema, partial) or {} combined_extra = {**hook_data, **extra_data} - result = await self.on_update(id, schema, partial, **combined_extra) + result = await self.on_update(target, schema, partial, **combined_extra) if not result: raise EntityNotFound(message=f"{self.model.__name__} not found.") await self.post_update(result) @@ -311,7 +338,10 @@ async def update( return result async def update_multi( - self, data: dict[Any, BaseModel], partial: bool = False, refresh: bool = False + self, + data: dict[ModelType | Any, BaseModel], + partial: bool = False, + refresh: bool = False, ) -> Sequence[ModelType]: """Orchestrate batch modifications to multiple existing domain entities.""" await self.pre_update_multi(data, partial) @@ -320,10 +350,20 @@ async def update_multi( await self._safe_commit() return result - async def delete(self, id: Any) -> ModelType: - """Orchestrate the deletion and cleanup of a single domain entity.""" - await self.pre_delete(id) - result = await self.on_delete(id) + async def delete(self, target: ModelType | Any) -> ModelType: + """Orchestrate the deletion and cleanup of a single domain entity. + + Args: + target: The model instance or primary key value of the target record to delete. + + Returns: + The deleted database model instance. + + Raises: + EntityNotFound: If the target entity identifier is not found in the database. + """ + await self.pre_delete(target) + result = await self.on_delete(target) if not result: raise EntityNotFound(message=f"{self.model.__name__} not found.") await self.post_delete(result) @@ -390,4 +430,4 @@ def __init__(self, model: type[ModelType], repository: "BaseRepository") -> None repository: The data persistence layer coordinating model access. """ self.model = model - self.repository = repository + self.repository = repository \ No newline at end of file diff --git a/src/zcore/storage/base.py b/src/zcore/storage/base.py index 30e221b..fe3f468 100644 --- a/src/zcore/storage/base.py +++ b/src/zcore/storage/base.py @@ -1,8 +1,8 @@ """Storage Provider Base Interface. This module defines the primary storage contract for the ZCore framework, facilitating -file uploads, raw binary streaming, and secure asset deletions. It also provides -a FastAPI dependency stub to dynamically resolve storage providers from the IoC container. +file uploads, raw binary streaming, URL resolution, and secure asset deletions. It also +provides a FastAPI dependency stub to dynamically resolve storage providers from the IoC container. """ from abc import ABC, abstractmethod @@ -16,48 +16,72 @@ class StorageProvider(ABC): """Abstract Base Class specifying standard storage platform capabilities. - Implementations must inherit from this class to manage file operations (e.g., local - filesystems, AWS S3, or Google Cloud Storage) within the ZCore framework. + Implementations must inherit from this class to manage file operations across + local filesystems or distributed cloud object stores. """ @abstractmethod - async def upload(self, file: UploadFile, folder: str) -> str: - """Upload a file to the configured storage target. + async def upload(self, file: UploadFile, folder: str = "") -> str: + """Upload an evaluated file payload and return its public or relative web URL. Args: - file: The validated UploadFile object representing the user input. - folder: The target sub-directory or category bucket for storage. + file: The validated UploadFile instance. + folder: Target subdirectory or bucket partition. Defaults to "". Returns: - The safe relative or absolute path of the persisted asset. + The normalized web URL path of the persisted asset. """ pass @abstractmethod async def upload_stream( - self, file_stream: AsyncGenerator[bytes, None], filename: str, folder: str + self, file_stream: AsyncGenerator[bytes, None], filename: str, folder: str = "" ) -> str: - """Stream binary raw chunks directly to the storage platform. + """Stream raw binary chunks directly to the storage target. Args: - file_stream: An asynchronous generator yielding chunks of file bytes. + file_stream: Asynchronous binary data chunk generator. filename: The target filename to assign to the streamed asset. - folder: The target sub-directory or bucket directory for storage. + folder: Target subdirectory or bucket partition. Defaults to "". Returns: - The safe path representing the persisted streamed asset. + The normalized web URL path of the persisted asset. """ pass @abstractmethod - async def delete(self, file_path: str) -> bool: - """Securely delete a file from the storage platform. + async def delete(self, file_path_or_url: str) -> bool: + """Securely remove a file from the storage platform. Args: - file_path: The stored path of the file to remove. + file_path_or_url: The stored URL or identifier of the asset to delete. Returns: - True if the deletion succeeds, False otherwise. + True if deletion succeeds, False otherwise. + """ + pass + + @abstractmethod + async def exists(self, file_path_or_url: str) -> bool: + """Verify the physical presence of an asset on the storage platform. + + Args: + file_path_or_url: The stored URL or identifier of the target asset. + + Returns: + True if the target asset exists, False otherwise. + """ + pass + + @abstractmethod + async def get_url(self, file_path_or_url: str) -> str: + """Resolve the publicly accessible web URL for an asset. + + Args: + file_path_or_url: The stored identifier or relative asset path. + + Returns: + The formatted web URL string. """ pass @@ -71,4 +95,4 @@ async def get_storage_provider(provider: Inject[StorageProvider]) -> StorageProv Returns: The active storage provider instance. """ - return provider + return provider \ No newline at end of file diff --git a/src/zcore/storage/local.py b/src/zcore/storage/local.py index 512053f..951f524 100644 --- a/src/zcore/storage/local.py +++ b/src/zcore/storage/local.py @@ -1,13 +1,14 @@ """Local Filesystem Storage Provider. This module implements storage operations targeting the host machine's filesystem. -It protects against path traversal attacks by enforcing strict path bounds checks and -evaluating user-supplied files through configurable security validators. +It protects against path traversal attacks by enforcing strict path bounds checks, generates +high-entropy collision-free identifiers, resolves web-accessible URL paths dynamically, and evaluates +user-supplied files through configurable security validators. """ import uuid from collections.abc import AsyncGenerator -from pathlib import PureWindowsPath +from pathlib import Path as StdPath import aiofiles import structlog @@ -22,62 +23,118 @@ class LocalStorageProvider(StorageProvider): - """Storage provider targeting the host filesystem. - - Saves incoming files to local directories, utilizing randomized naming structures - to prevent file name collisions and path validation to prevent directory traversal. + """Storage provider targeting the host filesystem with secure web URL resolution. Attributes: - base_path: The absolute or relative root path of the host upload directory. + base_path: Absolute resolved root path of the host upload directory. + url_prefix: Normalized HTTP URL prefix mapped to exposed static assets. validators: List of security/validation rules executed against uploaded files. """ def __init__( - self, base_path: str, validators: list[BaseStorageValidator] | None = None + self, + base_path: str = "./storage", + url_prefix: str = "/storage", + validators: list[BaseStorageValidator] | None = None, ) -> None: """Initialize the LocalStorageProvider. Args: - base_path: The root directory reserved for file uploads. - validators: Configured file validators. Defaults to an empty list. + base_path: The root directory reserved for file uploads. Defaults to "./storage". + url_prefix: The HTTP prefix mapped to static uploads. Defaults to "/storage". + validators: Configured file validators. Defaults to None. """ - self.base_path = base_path + self.raw_base_path = base_path + self.base_path = StdPath(base_path).resolve() + self.url_prefix = "/" + url_prefix.strip("/\\") if url_prefix.strip("/\\") else "" self.validators = validators or [] + self.base_path.mkdir(parents=True, exist_ok=True) + + def _extract_key(self, file_path_or_url: str) -> str | None: + """Extract the normalized storage key from a web URL, storage key, or filesystem path. + + Args: + file_path_or_url: Web URL, storage key, or filesystem path. + + Returns: + Normalized POSIX storage key, or None if an explicit path attempts sandbox traversal. + """ + cleaned = file_path_or_url.replace("\\", "/").strip() + + if self.url_prefix and cleaned.startswith(self.url_prefix): + return cleaned[len(self.url_prefix) :].lstrip("/") + + try: + cand = StdPath(cleaned).resolve() + if cand.is_relative_to(self.base_path): + return cand.relative_to(self.base_path).as_posix() + if StdPath(cleaned).is_absolute() or cleaned.startswith(("./", "../")): + return None + except Exception: + pass + + return cleaned.lstrip("/") + + def _key_to_path(self, key: str | None) -> StdPath | None: + """Safely resolve a storage key to an absolute filesystem path within sandbox boundaries. + + Args: + key: Normalized storage key. - async def generate_path(self, filename: str, related_type: str) -> str: - """Generate a secure, sanitized, and collision-resistant path for a new file. + Returns: + Resolved absolute StdPath if within base_path boundaries, or None if outside. + """ + if not key: + return None + + try: + resolved = (self.base_path / key).resolve() + if resolved.is_relative_to(self.base_path) and resolved != self.base_path: + return resolved + return None + except Exception: + return None + + def _resolve_path(self, file_path_or_url: str) -> StdPath | None: + """Resolve any incoming URL, storage key, or path to a validated filesystem path. + + Args: + file_path_or_url: Web URL, storage key, or relative/absolute path. - Enforces strict subdirectory constraints to mitigate path traversal exploits. + Returns: + Resolved absolute StdPath if valid and within boundaries, or None otherwise. + """ + if not file_path_or_url: + return None + key = self._extract_key(file_path_or_url) + return self._key_to_path(key) + + async def _resolve_destination(self, filename: str, folder: str = "") -> tuple[StdPath, str]: + """Generate verified physical filesystem target paths and corresponding web URL representations. Args: - filename: The original name of the file to extract extensions from. - related_type: Category identifier mapping to subfolder structures. + filename: Original name of the target file. + folder: Subdirectory categorization partition. Defaults to "". Returns: - The fully resolved destination path string on the local filesystem. + A tuple containing the absolute physical destination path and the relative web URL. Raises: AppException: If a directory traversal attempt is detected. """ - base = await Path(self.base_path).resolve(strict=False) - normalized_type = PureWindowsPath(related_type).as_posix() - target_folder = await (Path(self.base_path) / normalized_type).resolve( - strict=False - ) + normalized_folder = folder.strip("/\\").replace("\\", "/") + ext = StdPath(filename).suffix.lower() + secure_filename = f"{uuid.uuid4().hex}{ext}" + storage_key = f"{normalized_folder}/{secure_filename}" if normalized_folder else secure_filename - if not target_folder.is_relative_to(base): + physical_path = self._key_to_path(storage_key) + if physical_path is None: raise AppException("Path traversal attempt detected") - uuid_file_name = str(uuid.uuid4())[:15] - ext = Path(filename).suffix.lower() - - new_file_name = f"{uuid_file_name}{ext}" - final_path = target_folder / new_file_name + await Path(str(physical_path.parent)).mkdir(parents=True, exist_ok=True) + web_url = f"{self.url_prefix}/{storage_key}" if self.url_prefix else f"/{storage_key}" - if not await target_folder.exists(): - await target_folder.mkdir(parents=True, exist_ok=True) - - return final_path.as_posix() + return physical_path, web_url def _validate_file(self, file: UploadFile) -> None: """Internal helper running the active validator list against a file. @@ -88,18 +145,15 @@ def _validate_file(self, file: UploadFile) -> None: for validator in self.validators: validator(file) - async def upload(self, file: UploadFile, related_type: str) -> str: - """Upload a file to the local directory path. - - Applies configured file validation, generates a secure path, and streams - the raw file chunks to disk asynchronously. + async def upload(self, file: UploadFile, folder: str = "") -> str: + """Upload a file to the local storage target and return its normalized web URL. Args: file: The UploadFile payload to persist. - related_type: Category identifier mapping to subfolder structures. + folder: Subdirectory categorization partition. Defaults to "". Returns: - The resolved local path of the saved file. + The normalized web URL path of the saved file. Raises: AppException: If path traversal is detected or a filesystem write failure occurs. @@ -107,74 +161,112 @@ async def upload(self, file: UploadFile, related_type: str) -> str: self._validate_file(file) try: - path = await self.generate_path(file.filename or "file", related_type) - async with aiofiles.open(path, "wb") as buffer: + physical_path, web_url = await self._resolve_destination(file.filename or "file", folder) + async with aiofiles.open(physical_path, "wb") as buffer: while chunk := await file.read(1024 * 1024): await buffer.write(chunk) - except AppException as e: - raise e + return web_url + except AppException: + raise except Exception as e: - logger.error( - f"Failed to upload file to local storage due to system error: {e}" - ) + logger.error(f"Failed to upload file to local storage due to system error: {e}") raise AppException("Error saving file") - return path async def upload_stream( - self, file_stream: AsyncGenerator[bytes, None], filename: str, related_type: str + self, file_stream: AsyncGenerator[bytes, None], filename: str, folder: str = "" ) -> str: - """Directly stream binary data chunks to a local file destination. + """Directly stream binary data chunks to a local file destination and return its web URL. Args: - file_stream: Asynchronous binary data generator. + file_stream: Asynchronous binary data chunk generator. filename: Target file name. - related_type: Category identifier mapping to subfolder structures. + folder: Subdirectory categorization partition. Defaults to "". Returns: - The local path of the saved file. + The normalized web URL path of the saved file. Raises: AppException: If path traversal is detected or a filesystem write failure occurs. """ try: - path = await self.generate_path(filename, related_type) - async with aiofiles.open(path, "wb") as buffer: + physical_path, web_url = await self._resolve_destination(filename, folder) + async with aiofiles.open(physical_path, "wb") as buffer: async for chunk in file_stream: await buffer.write(chunk) - except AppException as e: - raise e + return web_url + except AppException: + raise except Exception as e: - logger.error( - f"Failed to stream upload file to local storage due to system error: {e}" - ) + logger.error(f"Failed to stream upload file to local storage due to system error: {e}") raise AppException("Error saving file") - return path - - async def delete(self, file_path: str) -> bool: - """Securely remove a file from local storage. - Verifies that the target path resides within the configured root directory - before unlinking the asset to prevent arbitrary file deletion exploits. + async def delete(self, file_path_or_url: str) -> bool: + """Securely remove a file from local storage using its physical path or web URL. Args: - file_path: Relative or absolute local path of the file to remove. + file_path_or_url: Stored web URL or relative path of the file to remove. Returns: True if unlinking succeeds, False if traversal is detected or deletion fails. """ - try: - base = await Path(self.base_path).resolve(strict=False) - normalized_path = PureWindowsPath(file_path).as_posix() - path_to_delete = await Path(normalized_path).resolve(strict=False) + if not file_path_or_url: + return False - if not path_to_delete.is_relative_to(base): - logger.warning( - f"Prevented arbitrary file deletion attempt outside base path: {file_path}" - ) - return False + target_file = self._resolve_path(file_path_or_url) + if target_file is None: + logger.warning( + f"Prevented arbitrary file deletion attempt outside base path: {file_path_or_url}" + ) + return False - await path_to_delete.unlink(missing_ok=True) + try: + path_obj = Path(str(target_file)) + await path_obj.unlink(missing_ok=True) return True except Exception as e: - logger.error(f"Failed to delete file: {file_path}. Error: {e}") + logger.error(f"Failed to delete file '{file_path_or_url}': {e}") + return False + + async def exists(self, file_path_or_url: str) -> bool: + """Verify the existence of a file within the local storage sandbox. + + Args: + file_path_or_url: Stored web URL or relative path of the target file. + + Returns: + True if the target file exists and is within storage boundaries, False otherwise. + """ + if not file_path_or_url: return False + + target_file = self._resolve_path(file_path_or_url) + if target_file is None: + return False + + try: + path_obj = Path(str(target_file)) + return await path_obj.exists() + except Exception: + return False + + async def get_url(self, file_path_or_url: str) -> str: + """Resolve the normalized web-accessible URL for a stored asset. + + Args: + file_path_or_url: Stored web URL or relative path of the target file. + + Returns: + The normalized web URL string. + """ + if not file_path_or_url: + return "" + + cleaned = file_path_or_url.replace("\\", "/").strip() + if self.url_prefix and cleaned.startswith(self.url_prefix): + return cleaned + + key = self._extract_key(file_path_or_url) + if not key: + return "" + + return f"{self.url_prefix}/{key}" if self.url_prefix else f"/{key}" \ No newline at end of file diff --git a/src/zcore/storage/validators.py b/src/zcore/storage/validators.py index 0401cfa..fbf869a 100644 --- a/src/zcore/storage/validators.py +++ b/src/zcore/storage/validators.py @@ -2,11 +2,12 @@ This module provides validation controls to protect server integrity during file uploads. It includes file extension checks, file size limit enforcement to defend against Denial -of Service (DoS) attacks, and MIME-type verification using magic bytes to detect -masqueraded executable scripts. +of Service (DoS) attacks, and MIME-type verification using magic bytes and payload inspection +to detect masqueraded executable scripts and stored XSS vectors. """ import mimetypes +import re import structlog from fastapi import UploadFile @@ -15,8 +16,7 @@ logger = structlog.get_logger() -# Hardened digital signatures of popular safe formats (Magic Bytes) -SIGNATURES = { +SIGNATURES: dict[bytes, str] = { b"\x89PNG\r\n\x1a\n": "image/png", b"\xff\xd8\xff": "image/jpeg", b"GIF87a": "image/gif", @@ -25,6 +25,29 @@ b"RIFF": "image/webp", } +BLOCKED_HEADER_PREFIXES: tuple[bytes, ...] = ( + b"MZ", + b"#!/", + b"\x7fELF", +) + +MALICIOUS_CONTENT_PATTERNS: tuple[re.Pattern[bytes], ...] = ( + re.compile(rb"<\?php", re.IGNORECASE), + re.compile(rb"]", re.IGNORECASE), + re.compile(rb"javascript:", re.IGNORECASE), + re.compile(rb"vbscript:", re.IGNORECASE), + re.compile(rb"data:text/html", re.IGNORECASE), + re.compile(rb"]", re.IGNORECASE), + re.compile(rb"]", re.IGNORECASE), + re.compile(rb"]", re.IGNORECASE), + re.compile(rb"]", re.IGNORECASE), + re.compile(rb"onload\s*=", re.IGNORECASE), + re.compile(rb"onerror\s*=", re.IGNORECASE), + re.compile(rb"onclick\s*=", re.IGNORECASE), + re.compile(rb"xlink:href\s*=\s*[\"']javascript:", re.IGNORECASE), +) + class BaseStorageValidator: """Base interface protocol defining standard storage validators.""" @@ -46,7 +69,7 @@ class FileExtensionValidator(BaseStorageValidator): """Enforce extension checks on incoming file names. Attributes: - allowed_extensions: Normalized list of allowed file extensions (e.g., '.png'). + allowed_extensions: Normalized list of allowed file extensions. message: Diagnostic warning message dispatched on failures. """ @@ -54,7 +77,7 @@ def __init__( self, allowed_extensions: list[str] | set[str], message: str | None = None, - ): + ) -> None: """Initialize the FileExtensionValidator. Args: @@ -95,7 +118,7 @@ class MaxFileSizeValidator(BaseStorageValidator): message: Diagnostic warning message dispatched on failures. """ - def __init__(self, max_size_mb: float, message: str | None = None): + def __init__(self, max_size_mb: float, message: str | None = None) -> None: """Initialize the MaxFileSizeValidator. Args: @@ -115,12 +138,10 @@ def __call__(self, file: UploadFile) -> None: Raises: ValidationError: If the file size exceeds limits or cannot be verified. """ - # Utilize Starlette's native file.size if populated, fallback to seek evaluation size = getattr(file, "size", None) if size is None: try: - # Fallback seeking for chunked multipart parsers file.file.seek(0, 2) size = file.file.tell() file.file.seek(0) @@ -133,18 +154,17 @@ def __call__(self, file: UploadFile) -> None: class SafeMimeTypeValidator(BaseStorageValidator): - """MIME-type validator utilizing Magic Byte verification. + """MIME-type validator utilizing Magic Byte verification and active payload inspection. - Reads the header bytes of the file payload to verify that the file's content - matches its declared format. Blocks high-risk patterns such as PHP tag injections, - HTML script tags, Unix shebang scripts, and Windows executables. + Reads the initial byte buffers of the file payload to verify that content matches declared formats + and scans against active script injections, executable headers, and stored XSS vectors. Attributes: allowed_mimes: Set containing approved MIME keys. message: Diagnostic warning message dispatched on failures. """ - def __init__(self, allowed_mimes: list[str] | set[str], message: str | None = None): + def __init__(self, allowed_mimes: list[str] | set[str], message: str | None = None) -> None: """Initialize the SafeMimeTypeValidator. Args: @@ -158,7 +178,7 @@ def __init__(self, allowed_mimes: list[str] | set[str], message: str | None = No ) def __call__(self, file: UploadFile) -> None: - """Analyze file headers to detect masquerading extensions or script injections. + """Analyze file headers and byte payloads for security compliance. Args: file: The uploaded file payload to inspect. @@ -168,38 +188,39 @@ def __call__(self, file: UploadFile) -> None: unauthorized file signatures. """ try: - header_bytes = file.file.read(2048) + sample_bytes = file.file.read(8192) file.file.seek(0) except Exception as e: - logger.error(f"Failed to read magic bytes for verification: {e}") + logger.error(f"Failed to read file header bytes for verification: {e}") raise ValidationError(message="Failed to validate file signatures.") - # Blocks PHP block tags, HTML script tags, Unix shebang scripts and Windows Executables - if ( - b" str: class CustomJSONEncoder(json.JSONEncoder): - """JSON encoder capable of serializing advanced Python data types. + """JSON encoder capable of serializing advanced Python data types with timezone awareness. Extends the standard library JSONEncoder to serialize instances of UUID, - datetime/date/time, and Decimal, falling back to a string representation - for other unrecognized objects to prevent serialization failures. + datetime/date/time, and Decimal, applying application-level timezone conversions + on datetime objects according to framework settings. """ def default(self, obj: Any) -> Any: @@ -56,7 +58,11 @@ def default(self, obj: Any) -> Any: """ if isinstance(obj, uuid.UUID): return str(obj) - if isinstance(obj, (datetime, date, time)): + if isinstance(obj, datetime): + if getattr(settings, "AUTO_CONVERT_TIMEZONE", True): + return format_iso_with_app_timezone(obj) + return obj.isoformat() + if isinstance(obj, (date, time)): return obj.isoformat() if isinstance(obj, Decimal): return str(obj) @@ -89,4 +95,4 @@ def json_loads(s: str | bytes, **kwargs: Any) -> Any: Returns: The parsed Python primitive values or collection structures. """ - return json.loads(s, **kwargs) + return json.loads(s, **kwargs) \ No newline at end of file diff --git a/src/zcore/utils/timezone.py b/src/zcore/utils/timezone.py new file mode 100644 index 0000000..481f140 --- /dev/null +++ b/src/zcore/utils/timezone.py @@ -0,0 +1,115 @@ +"""ZCore Dynamic Timezone and DateTime Management Module. + +This module provides timezone-aware datetime utilities leveraging Python's +standard `zoneinfo.ZoneInfo` (IANA database) with built-in fallback to `datetime.timezone.utc`. +""" + +import functools +import zoneinfo +from datetime import UTC, datetime +from typing import Annotated, Any + +import structlog +from pydantic import PlainSerializer + +from zcore.config import settings + +logger = structlog.get_logger() + + +@functools.lru_cache(maxsize=16) +def get_app_timezone() -> Any: + """Retrieve and cache the active application ZoneInfo object based on settings. + + Returns: + A timezone instance representing the application's configured timezone, + falling back to UTC if the configured identifier is invalid. + """ + tz_name = getattr(settings, "TIMEZONE", "UTC") + if not tz_name or str(tz_name).upper() == "UTC": + return UTC + try: + return zoneinfo.ZoneInfo(tz_name) + except Exception as e: + logger.warning( + "Invalid timezone configured, falling back to UTC", + timezone=tz_name, + error=str(e), + ) + return UTC + + +def now() -> datetime: + """Generate current timezone-aware datetime in the application's configured timezone. + + Returns: + Current datetime instance bound to the application timezone. + """ + return datetime.now(get_app_timezone()) + + +def utc_now() -> datetime: + """Generate current timezone-aware datetime in UTC. + + Returns: + Current datetime instance bound to UTC. + """ + return datetime.now(UTC) + + +def to_app_timezone(dt: datetime | None) -> datetime | None: + """Convert any datetime object to the application's configured timezone. + + If the input datetime is naive (lacks tzinfo), it is assumed to be stored in UTC + before undergoing offset transformation. + + Args: + dt: The source datetime object to convert, or None. + + Returns: + The converted datetime in the application's timezone, or None if input was None. + """ + if dt is None: + return None + + target_tz = get_app_timezone() + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + + return dt.astimezone(target_tz) + + +def format_iso_with_app_timezone(dt: datetime | None) -> str | None: + """Convert a datetime to application timezone and format as an ISO 8601 string. + + Args: + dt: The source datetime object to convert and format. + + Returns: + ISO 8601 formatted string with offset, or None if input is None. + """ + if dt is None: + return None + + if not getattr(settings, "AUTO_CONVERT_TIMEZONE", True): + return dt.isoformat() + + converted = to_app_timezone(dt) + return converted.isoformat() if converted is not None else None + + +ZDateTime = Annotated[ + datetime, + PlainSerializer( + format_iso_with_app_timezone, return_type=str, when_used="json" + ), +] + +__all__ = [ + "ZDateTime", + "format_iso_with_app_timezone", + "get_app_timezone", + "now", + "to_app_timezone", + "utc_now", +] \ No newline at end of file diff --git a/src/zcore/web/base_router.py b/src/zcore/web/base_router.py index ab44906..a7ed92b 100644 --- a/src/zcore/web/base_router.py +++ b/src/zcore/web/base_router.py @@ -2,7 +2,8 @@ This module provides the generic `BaseRouter` interface, which scaffolds standard security-aware CRUD endpoints (POST, GET, GET_ALL, SEARCH, UPDATE, PATCH, DELETE) -and integrates them with services, schemas, dependency requirements, and pagination handlers. +and integrates them with services, schemas, dependency requirements, and pagination handlers, +with clean declarative syntax, primary key auto-detection, and weighted route specificity sorting. """ import uuid @@ -41,10 +42,11 @@ class RouteKey(StrEnum): class BaseRouter(Generic[CreateSchemaType, UpdateSchemaType]): - """Generic web router orchestrator. + """Declarative web router orchestrator. - Automatically maps operations to matching database model dependencies and handles - dependency injections and schema checks. + Automatically maps CRUD operations to matching database model dependencies and handles + dependency injections, schema validations, dynamic primary key route resolution, + and hierarchical route ordering based on path specificity. Attributes: model: The database declarative model class. @@ -52,6 +54,7 @@ class BaseRouter(Generic[CreateSchemaType, UpdateSchemaType]): update_schema: Schema class for validating entity updates. schema_out: Schema class representing responses. service: Business service callable class. + pk_type: Optional explicit primary key type. If None, auto-resolved from model metadata. prefix: Path prefix representing the route. tags: Endpoint group classification tags. exclude: Explicit endpoints to bypass during route scaffolding. @@ -61,10 +64,11 @@ class BaseRouter(Generic[CreateSchemaType, UpdateSchemaType]): """ model: type[Any] - create_schema: type[CreateSchemaType] | None = None - update_schema: type[UpdateSchemaType] | None = None + create_schema: type[CreateSchemaType] | type[BaseModel] | None = None + update_schema: type[UpdateSchemaType] | type[BaseModel] | None = None schema_out: type[BaseModel] | None = None service: Any = None + pk_type: type[Any] | None = None prefix: str = "" tags: list[str] | None = None @@ -82,7 +86,6 @@ def __init__(self) -> None: Raises: ValueError: If the required service parameter is not configured. """ - # Fail-Fast Startup Verification if not self.service: raise ValueError( f"Service class must be defined in '{self.__class__.__name__}'." @@ -95,6 +98,45 @@ def __init__(self) -> None: self._validate_schema_configurations() self._register_routes() + def _resolve_pk_type(self) -> type[Any]: + """Resolve the effective primary key type from explicit definition or model reflection. + + Returns: + The resolved python type class for the primary key. + """ + if getattr(self, "pk_type", None) is not None: + return self.pk_type + + if getattr(self, "model", None) is not None: + try: + from sqlalchemy import inspect + + mapper = inspect(self.model) + if mapper.primary_key: + pk_col = mapper.primary_key[0] + python_type = getattr(pk_col.type, "python_type", None) + if python_type is not None: + return python_type + except Exception: + pass + + return uuid.UUID + + def _get_pk_path(self, pk_type: type[Any]) -> str: + """Construct the URL path segment matching the primary key converter. + + Args: + pk_type: The resolved primary key type. + + Returns: + The formatted route path string. + """ + if pk_type is int: + return "/{id:int}" + if pk_type is uuid.UUID: + return "/{id:uuid}" + return "/{id}" + def _validate_schema_configurations(self) -> None: """Perform validation checks on configured route schema definitions. @@ -186,17 +228,14 @@ def get_route_action(self, route_key: RouteKey) -> str: return action_map[route_key] def get_route_dependencies(self, route_key: RouteKey, action: str) -> list[Any]: - """Generate default route dependencies (authentication, authorization, logging, etc.). - - Subclasses can override this method to inject dynamic, runtime dependencies - with full OOP flexibility. + """Generate default route dependencies. Args: route_key: The target operational route key. action: The computed database/permission action identifier. Returns: - A list of dependencies (callable, classes or FastAPI Depends objects). + A list of dependencies. """ if not action: return [] @@ -220,16 +259,34 @@ def _get_route_dependencies(self, route_key: RouteKey) -> list[DependsClass]: return self._normalize_dependencies(dependencies) def _sort_routes(self) -> None: - """Sort routes to place parameterized dynamic paths at the end of the routing table. + """Sort routes using hierarchical specificity scoring to avoid path shadowing.""" - This avoids dynamic routes (e.g., /{id:uuid}) from shadowing static sibling endpoints (e.g., /search). - """ - self.router.routes.sort(key=lambda r: "{" in r.path) + def _route_specificity_key(route: Any) -> tuple[int, int, list[int], int]: + path = getattr(route, "path", "") + segments = [seg for seg in path.strip("/").split("/") if seg] + + segment_scores: list[int] = [] + for seg in segments: + if seg.startswith("{") and seg.endswith("}"): + if ":path}" in seg: + segment_scores.append(2) + else: + segment_scores.append(1) + else: + segment_scores.append(0) + + has_dynamic = 1 if any(s > 0 for s in segment_scores) else 0 + total_dynamic = sum(segment_scores) + return (has_dynamic, total_dynamic, segment_scores, -len(segments)) + + self.router.routes.sort(key=_route_specificity_key) def _register_routes(self) -> None: """Dynamically generate and bind endpoints to the APIRouter.""" service_callable = self.service service_dependency = Depends(Injector(service_callable)) + target_pk_type = self._resolve_pk_type() + pk_path = self._get_pk_path(target_pk_type) if RouteKey.POST not in self.exclude: c_schema = self.create_schema @@ -237,9 +294,8 @@ def _register_routes(self) -> None: async def _create_endpoint( data_in: c_schema, service_inst: BaseService = service_dependency, - **kwargs: Any, ) -> ResponseWrapper: - return await self.create_endpoint(data_in, service_inst, **kwargs) + return await self.create_endpoint(data_in, service_inst) self.router.add_api_route( path="/", @@ -256,14 +312,13 @@ async def _create_endpoint( if RouteKey.GET not in self.exclude: async def _get_endpoint( - id: uuid.UUID, + id: target_pk_type, service_inst: BaseService = service_dependency, - **kwargs: Any, ) -> ResponseWrapper: - return await self.get_endpoint(id, service_inst, **kwargs) + return await self.get_endpoint(id, service_inst) self.router.add_api_route( - path="/{id:uuid}", + path=pk_path, endpoint=_get_endpoint, methods=["GET"], dependencies=self._get_route_dependencies(RouteKey.GET), @@ -281,15 +336,14 @@ async def _get_endpoint( async def _get_all_endpoint( params: params_class = Depends(), service_inst: BaseService = service_dependency, - **kwargs: Any, ) -> ResponseWrapper: - return await self.get_all_endpoint(service_inst, params, **kwargs) + return await self.get_all_endpoint(service_inst, params) else: async def _get_all_endpoint( - service_inst: BaseService = service_dependency, **kwargs: Any + service_inst: BaseService = service_dependency, ) -> ResponseWrapper: - return await self.get_all_endpoint(service_inst, **kwargs) + return await self.get_all_endpoint(service_inst) self.router.add_api_route( path="/", @@ -308,9 +362,8 @@ async def _get_all_endpoint( async def _search_endpoint( search_in: SearchRequest, service_inst: BaseService = service_dependency, - **kwargs: Any, ) -> ResponseWrapper: - return await self.search_endpoint(search_in, service_inst, **kwargs) + return await self.search_endpoint(search_in, service_inst) self.router.add_api_route( path="/search", @@ -328,15 +381,14 @@ async def _search_endpoint( u_schema = self.update_schema async def _update_endpoint( - id: uuid.UUID, + id: target_pk_type, data_in: u_schema, service_inst: BaseService = service_dependency, - **kwargs: Any, ) -> ResponseWrapper: - return await self.update_endpoint(id, data_in, service_inst, **kwargs) + return await self.update_endpoint(id, data_in, service_inst) self.router.add_api_route( - path="/{id:uuid}", + path=pk_path, endpoint=_update_endpoint, methods=["PUT"], dependencies=self._get_route_dependencies(RouteKey.UPDATE), @@ -351,15 +403,14 @@ async def _update_endpoint( u_schema = self.update_schema async def _patch_endpoint( - id: uuid.UUID, + id: target_pk_type, data_in: u_schema, service_inst: BaseService = service_dependency, - **kwargs: Any, ) -> ResponseWrapper: - return await self.patch_endpoint(id, data_in, service_inst, **kwargs) + return await self.patch_endpoint(id, data_in, service_inst) self.router.add_api_route( - path="/{id:uuid}", + path=pk_path, endpoint=_patch_endpoint, methods=["PATCH"], dependencies=self._get_route_dependencies(RouteKey.PATCH), @@ -373,14 +424,13 @@ async def _patch_endpoint( if RouteKey.DELETE not in self.exclude: async def _delete_endpoint( - id: uuid.UUID, + id: target_pk_type, service_inst: BaseService = service_dependency, - **kwargs: Any, ) -> ResponseWrapper: - return await self.delete_endpoint(id, service_inst, **kwargs) + return await self.delete_endpoint(id, service_inst) self.router.add_api_route( - path="/{id:uuid}", + path=pk_path, endpoint=_delete_endpoint, methods=["DELETE"], dependencies=self._get_route_dependencies(RouteKey.DELETE), @@ -392,14 +442,13 @@ async def _delete_endpoint( self._sort_routes() async def create_endpoint( - self, data_in: CreateSchemaType, service: BaseService, **kwargs: Any + self, data_in: Any, service: BaseService ) -> ResponseWrapper: """Execute the POST creation transaction. Args: data_in: Validated input schema containing creation properties. service: Active business service instance. - **kwargs: Extra dynamic route arguments. Returns: The created entity wrapped in a ResponseWrapper. @@ -408,14 +457,13 @@ async def create_endpoint( return ResponseWrapper(data=data) async def get_endpoint( - self, id: uuid.UUID, service: BaseService, **kwargs: Any + self, id: Any, service: BaseService ) -> ResponseWrapper: """Execute a single-record query lookup. Args: id: The primary key of the target entity. service: Active business service instance. - **kwargs: Extra dynamic route arguments. Returns: The resolved model record wrapped in a ResponseWrapper. @@ -424,19 +472,18 @@ async def get_endpoint( return ResponseWrapper(data=data) async def get_all_endpoint( - self, service: BaseService, pagination: Any = None, **kwargs: Any + self, service: BaseService, pagination: Any = None ) -> ResponseWrapper: """Execute batch query listings, applying optional page boundaries. Args: service: Active business service instance. pagination: Optional offset or keyset cursor parameters. Defaults to None. - **kwargs: Extra dynamic route arguments. Returns: The list of resolved model records wrapped in a ResponseWrapper. """ - result = await service.get_list(pagination) + result = await service.get_list(pagination=pagination) from zcore.db.pagination import PaginatedResult if isinstance(result, PaginatedResult): @@ -444,22 +491,21 @@ async def get_all_endpoint( return ResponseWrapper(data=result) async def search_endpoint( - self, search_in: SearchRequest, service: BaseService, **kwargs: Any + self, search_in: SearchRequest, service: BaseService ) -> ResponseWrapper: """Execute dynamic filter searches, applying mapped page limits. Args: search_in: Target filtering limits request parameters. service: Active business service instance. - **kwargs: Extra dynamic route arguments. Returns: The matching model records wrapped in a ResponseWrapper. """ pagination = None - from zcore.db.pagination import CursorParams, PageNumberParams - if self.pagination_class: + from zcore.db.pagination import CursorParams, PageNumberParams + if self.pagination_class.params_class == CursorParams: pagination = CursorParams(cursor=search_in.cursor, size=search_in.size) else: @@ -474,10 +520,9 @@ async def search_endpoint( async def update_endpoint( self, - id: uuid.UUID, - data_in: UpdateSchemaType, + id: Any, + data_in: Any, service: BaseService, - **kwargs: Any, ) -> ResponseWrapper: """Execute a full-record entity update transaction. @@ -485,7 +530,6 @@ async def update_endpoint( id: Target entity identifier to update. data_in: Validated schema containing updated properties. service: Active business service instance. - **kwargs: Extra dynamic route arguments. Returns: The updated model record wrapped in a ResponseWrapper. @@ -495,10 +539,9 @@ async def update_endpoint( async def patch_endpoint( self, - id: uuid.UUID, - data_in: UpdateSchemaType, + id: Any, + data_in: Any, service: BaseService, - **kwargs: Any, ) -> ResponseWrapper: """Execute a partial record update (PATCH) transaction. @@ -506,7 +549,6 @@ async def patch_endpoint( id: Target entity identifier to patch. data_in: Validated schema containing partial changes. service: Active business service instance. - **kwargs: Extra dynamic route arguments. Returns: The updated model record wrapped in a ResponseWrapper. @@ -515,17 +557,16 @@ async def patch_endpoint( return ResponseWrapper(data=data) async def delete_endpoint( - self, id: uuid.UUID, service: BaseService, **kwargs: Any + self, id: Any, service: BaseService ) -> ResponseWrapper: """Execute a single-record delete transaction. Args: id: Target entity identifier to delete. service: Active business service instance. - **kwargs: Extra dynamic route arguments. Returns: A success response wrapped in a ResponseWrapper. """ await service.delete(id) - return ResponseWrapper(message="Deleted successfully") + return ResponseWrapper(message="Deleted successfully") \ No newline at end of file diff --git a/src/zcore/web/middleware.py b/src/zcore/web/middleware.py index 60d0bb7..c8be123 100644 --- a/src/zcore/web/middleware.py +++ b/src/zcore/web/middleware.py @@ -1,9 +1,9 @@ """Web Middleware Implementations. This module provides ASGI middleware to coordinate request lifecycles. It includes -`RequestLogMiddleware` to trace execution durations and manage request correlation headers, +`RequestLogMiddleware` to trace execution durations, status codes, and request correlation headers, and `ScopedDependencyMiddleware` to manage the lifecycle of request-scoped dependency injection -container boundaries. +container boundaries and asynchronous database sessions. """ import re @@ -28,7 +28,7 @@ class RequestLogMiddleware: Intercepts HTTP requests, extracts or generates correlation IDs, binds them to structured logging contextvars, appends the correlation ID to response headers, - and logs request durations. + and logs request metrics including method, path, status code, client IP, and duration. Attributes: app: The downstream ASGI application instance. @@ -59,7 +59,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: raw_request_id = b"" for name, value in scope.get("headers", []): - if name == b"x-request-id": # Standard lowercased header in ASGI spec + if name == b"x-request-id": raw_request_id = value break @@ -76,34 +76,45 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: structlog.contextvars.bind_contextvars(request_id=request_id) + status_code = 500 + async def send_wrapper(message: dict[str, Any]) -> None: - """Intercept response start and append the request correlation ID header. + """Intercept response start, capture status code, and append correlation header. Args: message: The outgoing ASGI event dictionary. """ + nonlocal status_code if message["type"] == "http.response.start": + status_code = message.get("status", 200) headers = list(message.get("headers", [])) headers.append((b"x-request-id", request_id.encode())) message["headers"] = headers await send(message) token = ctx.initialize() + client = scope.get("client") + client_ip = client[0] if client else "unknown" + try: await self.app(scope, receive, send_wrapper) duration = (time.perf_counter() - s_time) * 1000 log.info( - "http request", + "http_request", method=scope.get("method"), path=scope.get("path"), + status_code=status_code, + client_ip=client_ip, duration_ms=round(duration, 2), ) except Exception: duration = (time.perf_counter() - s_time) * 1000 log.exception( - "http request failed", + "http_request_failed", method=scope.get("method"), path=scope.get("path"), + status_code=status_code, + client_ip=client_ip, duration_ms=round(duration, 2), ) raise @@ -115,8 +126,8 @@ class ScopedDependencyMiddleware: """ASGI middleware to isolate request-scoped dependencies. Initializes a new context scope key on incoming requests, binds it to the DI - context variables, and executes a cleanup sweep of the scope's registered instances - upon connection closure. + context variables, allocates an active database session for the request lifecycle, + and executes a cleanup sweep of the scope's registered instances upon connection closure. Attributes: app: The downstream ASGI application instance. @@ -145,13 +156,10 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: scope_id = str(uuid.uuid4()) token = _current_scope_id.set(scope_id) - # Capture background tasks context if integrated with FastAPI - # We defer scope teardown if there are pending background tasks try: async with db_manager.session() as session: container.register_scoped_instance(AsyncSession, session) await self.app(scope, receive, send) finally: - # Safely clear the active IoC scope container.clear_scope(scope_id) - _current_scope_id.reset(token) + _current_scope_id.reset(token) \ No newline at end of file diff --git a/src/zcore/web/projection.py b/src/zcore/web/projection.py index dfa27bc..0bf9e7e 100644 --- a/src/zcore/web/projection.py +++ b/src/zcore/web/projection.py @@ -53,16 +53,19 @@ def _get_relative_restricted_paths(cls) -> set[str]: return relative_paths @classmethod - def _prune_data(cls, data: Any, relative_paths: set[str]) -> Any: - """Recursively strip restricted attributes from dict representations.""" - if not relative_paths or not isinstance(data, dict): + def _prune_data( + cls, + data: Any, + relative_paths: set[str], + ) -> Any: + """Recursively strip restricted attributes from dictionary representations.""" + if not isinstance(data, dict): return data if "*" in relative_paths: data.clear() return data - # Group remaining nested relative paths by top-level keys nested_restrictions: dict[str, set[str]] = {} for path in relative_paths: parts = path.split(".", 1) @@ -74,18 +77,28 @@ def _prune_data(cls, data: Any, relative_paths: set[str]) -> Any: nested_restrictions[key] = set() nested_restrictions[key].add(remaining) - # Process nested levels recursively - for key, remaining_paths in nested_restrictions.items(): - if key in data: - if isinstance(data[key], dict): - data[key] = cls._prune_data(data[key], remaining_paths) - elif isinstance(data[key], list): + for key in list(data.keys()): + val = data[key] + if key in nested_restrictions: + rem_paths = nested_restrictions[key] + if isinstance(val, dict): + data[key] = cls._prune_data(val, rem_paths) + elif isinstance(val, list): data[key] = [ - cls._prune_data(item, remaining_paths) + cls._prune_data(item, rem_paths) if isinstance(item, dict) else item - for item in data[key] + for item in val ] + elif isinstance(val, dict): + data[key] = cls._prune_data(val, set()) + elif isinstance(val, list): + data[key] = [ + cls._prune_data(item, set()) + if isinstance(item, dict) + else item + for item in val + ] return data @classmethod @@ -145,13 +158,13 @@ def filter_restricted_inputs(cls, data: Any) -> Any: @model_serializer(mode="wrap") def secure_serializer(self, handler: Any) -> Any: - """Securely intercept serialization to prune restricted attributes from response.""" + """Securely intercept serialization to prune restricted attributes.""" serialized = handler(self) relative_paths = self._get_relative_restricted_paths() - if not relative_paths or serialized is None: + if serialized is None: return serialized - if isinstance(serialized, dict): + if isinstance(serialized, dict) and relative_paths: serialized_copy = dict(serialized) return self._prune_data(serialized_copy, relative_paths) - return serialized + return serialized \ No newline at end of file diff --git a/tests/test_cache.py b/tests/test_cache.py index 9b4b22b..34fef18 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -2,12 +2,13 @@ import threading import time from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest from pydantic import BaseModel -from zcore.cache.base import BaseCache, close_cache, init_cache +import zcore.cache.base as base_module +from zcore.cache.base import BaseCache, _ensure_eviction_task, close_cache, init_cache from zcore.cache.ttllru_cache import TTLLRUCache, _active_caches @@ -15,6 +16,7 @@ class SampleCachedModel(BaseModel): id: int name: str + @pytest.mark.parametrize( "ttl_a, ttl_b, time_advancement, expected_a, expected_b", [ @@ -51,10 +53,11 @@ def test_ttllru_eviction_and_cleanup( current_time += 200 TTLLRUCache.evict_all_expired() - + with cache._lock: assert "c" not in cache.cache + @pytest.mark.anyio @pytest.mark.parametrize( "redis_healthy, simulate_exception", @@ -69,7 +72,7 @@ async def test_base_cache_redis_fallback( simulate_exception: bool ) -> None: cache = BaseCache[str](prefix="fallback_test") - + if not redis_healthy: monkeypatch.setattr("zcore.cache.base._shared_redis_client", None) assert cache.redis_client is None @@ -81,10 +84,11 @@ async def test_base_cache_redis_fallback( monkeypatch.setattr("zcore.cache.base._shared_redis_client", mock_client) await cache.set("safety_key", "secure_value", ttl=10) - + val = await cache.get("safety_key") assert val == "secure_value" + @pytest.mark.anyio @pytest.mark.parametrize( "payload, target_type, expected_cls", @@ -101,17 +105,18 @@ async def test_cache_deserialization_types( ) -> None: cache = BaseCache[Any](prefix="typing_test") await cache.set("payload_key", payload, ttl=5) - + retrieved = await cache.get("payload_key", target_type=target_type) assert retrieved is not None assert isinstance(retrieved, expected_cls) - + if target_type: assert retrieved.id == payload["id"] assert retrieved.name == payload["name"] else: assert retrieved == payload + def test_ttllru_lru_eviction() -> None: cache = TTLLRUCache(maxsize=3) cache.set("k1", "v1") @@ -124,23 +129,25 @@ def test_ttllru_lru_eviction() -> None: assert cache.get("k3") == "v3" assert cache.get("k4") == "v4" + def test_ttllru_inline_eviction_on_get(monkeypatch: pytest.MonkeyPatch) -> None: current_time = 1000.0 monkeypatch.setattr("time.time", lambda: current_time) monkeypatch.setattr("zcore.cache.ttllru_cache.time.time", lambda: current_time) - + cache = TTLLRUCache(maxsize=10) cache.set("key", "val", ttl=5) - + current_time += 10 assert cache.get("key") is None - + with cache._lock: assert "key" not in cache.cache + def test_ttllru_thread_safety() -> None: cache = TTLLRUCache(maxsize=100) - + def worker(worker_id: int) -> None: for i in range(50): key = f"key_{worker_id}_{i}" @@ -150,101 +157,106 @@ def worker(worker_id: int) -> None: cache.delete(key) cache.evict_expired() time.sleep(0.001) - + threads = [] for t_id in range(5): t = threading.Thread(target=worker, args=(t_id,)) threads.append(t) t.start() - + for t in threads: t.join() + def test_ttllru_weakref_gc() -> None: gc.collect() initial_count = len(list(_active_caches)) - + def create_temporary_cache() -> None: _ = TTLLRUCache(maxsize=5) assert len(list(_active_caches)) == initial_count + 1 - + create_temporary_cache() gc.collect() assert len(list(_active_caches)) == initial_count + def test_ttllru_overwrite_resets_ttl(monkeypatch: pytest.MonkeyPatch) -> None: current_time = 1000.0 monkeypatch.setattr("time.time", lambda: current_time) monkeypatch.setattr("zcore.cache.ttllru_cache.time.time", lambda: current_time) - + cache = TTLLRUCache() cache.set("k1", "v1", ttl=5) cache.set("k1", "v2", ttl=100) - + current_time += 10 assert cache.get("k1") == "v2" + def test_ttllru_explicit_delete() -> None: cache = TTLLRUCache() cache.set("k1", "v1") assert cache.get("k1") == "v1" cache.delete("k1") assert cache.get("k1") is None - + with cache._lock: assert "k1" not in cache.cache + @pytest.mark.anyio async def test_base_cache_prefix_isolation(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("zcore.cache.base._shared_redis_client", None) - + cache_users = BaseCache[str](prefix="users") cache_products = BaseCache[str](prefix="products") - + await cache_users.set("1", "alice") await cache_products.set("1", "laptop") - + assert await cache_users.get("1") == "alice" assert await cache_products.get("1") == "laptop" + @pytest.mark.anyio async def test_cache_global_lifecycle(monkeypatch: pytest.MonkeyPatch) -> None: - import zcore.cache.base as base_module monkeypatch.setattr(base_module, "_shared_redis_client", None) monkeypatch.setattr(base_module, "_eviction_task", None) - + init_cache(redis_url="redis://localhost:6379") - + assert base_module._eviction_task is not None assert not base_module._eviction_task.done() - + await close_cache() - + assert base_module._eviction_task is None assert base_module._shared_redis_client is None + @pytest.mark.anyio async def test_cache_double_init(monkeypatch: pytest.MonkeyPatch) -> None: - import zcore.cache.base as base_module monkeypatch.setattr(base_module, "_shared_redis_client", None) monkeypatch.setattr(base_module, "_eviction_task", None) - + init_cache() task1 = base_module._eviction_task assert task1 is not None - + init_cache() task2 = base_module._eviction_task assert task2 is task1 - + await close_cache() + @pytest.mark.anyio @pytest.mark.parametrize("redis_healthy", [True, False]) async def test_base_cache_delete_fallback(monkeypatch: pytest.MonkeyPatch, redis_healthy: bool) -> None: cache = BaseCache[str](prefix="del_test") await cache.set("k1", "v1") - + if redis_healthy: mock_client = AsyncMock() monkeypatch.setattr("zcore.cache.base._shared_redis_client", mock_client) @@ -258,12 +270,38 @@ async def test_base_cache_delete_fallback(monkeypatch: pytest.MonkeyPatch, redis await cache.delete("k1") assert await cache.get("k1") is None + @pytest.mark.anyio async def test_base_cache_corrupt_json_deserialization(monkeypatch: pytest.MonkeyPatch) -> None: cache = BaseCache[Any](prefix="corrupt_test") monkeypatch.setattr("zcore.cache.base._shared_redis_client", None) - + cache._local_cache.set("corrupt_test:corrupt_key", "{invalid_json_string}") - + val = await cache.get("corrupt_key") - assert val is None \ No newline at end of file + assert val is None + + +def test_ensure_eviction_task_outside_event_loop_safe(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(base_module, "_eviction_task", None) + with patch("asyncio.get_running_loop", side_effect=RuntimeError("no running event loop")): + _ensure_eviction_task() + assert base_module._eviction_task is None + + +@pytest.mark.anyio +async def test_close_cache_cancellation_suppression(monkeypatch: pytest.MonkeyPatch) -> None: + mock_redis = AsyncMock() + monkeypatch.setattr(base_module, "_shared_redis_client", mock_redis) + monkeypatch.setattr(base_module, "_eviction_task", None) + + base_module._ensure_eviction_task() + task = base_module._eviction_task + assert task is not None + assert not task.done() + + await close_cache() + + assert base_module._eviction_task is None + assert base_module._shared_redis_client is None + mock_redis.aclose.assert_called_once() \ No newline at end of file diff --git a/tests/test_cli.py b/tests/test_cli.py index 7d3c30c..3fb952a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,6 +7,7 @@ from zcore.cli import main from zcore.cli.commands import find_zcore_projects, is_zcore_project +from zcore.cli.ui import console, print_banner @pytest.fixture @@ -265,4 +266,33 @@ def test_scaffolded_templates_are_valid_python( for py_file in app_dir.glob("*.py"): code = py_file.read_text(encoding="utf-8") if code.strip(): - compile(code, str(py_file), "exec") \ No newline at end of file + compile(code, str(py_file), "exec") + + +def test_cli_version_output_beta_9() -> None: + with console.capture() as capture: + test_args = ["zc", "--version"] + with patch.object(sys, "argv", test_args): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + assert "0.1.0-rc.1" in capture.get() + + with console.capture() as capture: + print_banner() + assert "0.1.0-rc.1" in capture.get() + + +def test_cli_scaffold_templates_use_database_settings_config(run_in_tmp_path: Path) -> None: + project_name = "db_cfg_proj" + test_args = ["zc", "init", project_name, "-y"] + + with patch.object(sys, "argv", test_args): + main() + + project_dir = run_in_tmp_path / project_name + main_py_content = (project_dir / "main.py").read_text(encoding="utf-8") + env_content = (project_dir / ".env").read_text(encoding="utf-8") + + assert "db_manager.init_app(config=settings.DATABASE)" in main_py_content + assert 'TIMEZONE="UTC"' in env_content \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py index 04c84d5..076db3b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,7 +4,14 @@ import pytest -from zcore.config import Settings, get_settings, initialize_settings, settings +from zcore.config import ( + DatabaseSettings, + LoggingSettings, + Settings, + get_settings, + initialize_settings, + settings, +) from zcore.kernel.di import container @@ -28,6 +35,7 @@ def test_settings_environmental_loading( assert getattr(fresh_settings, check_attr) == expected_val assert fresh_settings.DATABASE_URL == "sqlite+aiosqlite:///zcore.db" + @pytest.mark.parametrize( "secret_a, secret_b", [ @@ -45,6 +53,7 @@ def test_settings_proxy_resolution(secret_a: str, secret_b: str) -> None: initialize_settings(settings_b) assert secret_b == settings.SECRET_KEY + def test_settings_type_coercion(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("DEBUG", "False") monkeypatch.setenv("POOL_SIZE", "15") @@ -53,6 +62,7 @@ def test_settings_type_coercion(monkeypatch: pytest.MonkeyPatch) -> None: assert fresh_settings.DEBUG is False assert fresh_settings.POOL_SIZE == 15 + def test_settings_case_sensitivity(monkeypatch: pytest.MonkeyPatch) -> None: if sys.platform == "win32": with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: @@ -70,12 +80,14 @@ def test_settings_case_sensitivity(monkeypatch: pytest.MonkeyPatch) -> None: fresh_settings = get_settings() assert fresh_settings.SECRET_KEY == "zcore-insecure-fallback-secret-key-must-be-changed" + def test_settings_extra_ignored(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("RANDOM_ZCORE_ENVIRONMENT_VARIABLE", "ignore-me") container._singletons.clear() fresh_settings = get_settings() assert not hasattr(fresh_settings, "RANDOM_ZCORE_ENVIRONMENT_VARIABLE") + def test_settings_custom_env_file(monkeypatch: pytest.MonkeyPatch) -> None: with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: f.write("SECRET_KEY=env-file-custom-secret-999\nPROJECT_NAME=EnvFileApp") @@ -90,10 +102,13 @@ def test_settings_custom_env_file(monkeypatch: pytest.MonkeyPatch) -> None: finally: os.unlink(temp_filename) + def test_settings_subclass_di_registration() -> None: container._singletons.clear() + class CustomProjectSettings(Settings): CUSTOM_FIELD: str = "custom_value" + custom_inst = CustomProjectSettings() initialize_settings(custom_inst) resolved_parent = container.resolve(Settings) @@ -102,6 +117,7 @@ class CustomProjectSettings(Settings): assert resolved_child is custom_inst assert resolved_parent.CUSTOM_FIELD == "custom_value" + def test_settings_get_settings_fallback() -> None: container._singletons.clear() assert Settings not in container._singletons @@ -109,14 +125,130 @@ def test_settings_get_settings_fallback() -> None: assert Settings in container._singletons assert container.resolve(Settings) is inst + def test_settings_proxy_missing_attribute() -> None: container._singletons.clear() with pytest.raises(AttributeError): _ = settings.NON_EXISTING_DATABASE_PORT + def test_settings_proxy_lazy_evaluation() -> None: container._singletons.clear() assert settings.SECRET_KEY == "zcore-insecure-fallback-secret-key-must-be-changed" new_inst = Settings(SECRET_KEY="lazy-evaluated-new-secret") initialize_settings(new_inst) - assert settings.SECRET_KEY == "lazy-evaluated-new-secret" \ No newline at end of file + assert settings.SECRET_KEY == "lazy-evaluated-new-secret" + + +def test_database_settings_model_defaults_and_custom() -> None: + default_db = DatabaseSettings() + assert default_db.url == "sqlite+aiosqlite:///zcore.db" + assert default_db.pool_size == 5 + assert default_db.max_overflow == 10 + assert default_db.pool_recycle == 1800 + assert default_db.pool_pre_ping is True + assert default_db.echo is False + assert default_db.connect_args == {} + assert default_db.execution_options == {} + assert default_db.extra_engine_kwargs == {} + + custom_db = DatabaseSettings( + url="postgresql+asyncpg://postgres:secret@localhost:5432/zcore_prod", + pool_size=20, + max_overflow=30, + pool_recycle=3600, + pool_pre_ping=False, + echo=True, + connect_args={"server_settings": {"jit": "off"}}, + execution_options={"isolation_level": "REPEATABLE READ"}, + extra_engine_kwargs={"isolation_level": "AUTOCOMMIT"}, + ) + assert custom_db.url == "postgresql+asyncpg://postgres:secret@localhost:5432/zcore_prod" + assert custom_db.pool_size == 20 + assert custom_db.max_overflow == 30 + assert custom_db.pool_recycle == 3600 + assert custom_db.pool_pre_ping is False + assert custom_db.echo is True + assert custom_db.connect_args == {"server_settings": {"jit": "off"}} + assert custom_db.execution_options == {"isolation_level": "REPEATABLE READ"} + assert custom_db.extra_engine_kwargs == {"isolation_level": "AUTOCOMMIT"} + + +def test_logging_settings_model_defaults_and_custom() -> None: + default_log = LoggingSettings() + assert default_log.level == "INFO" + assert default_log.json_format is None + assert default_log.log_sql_queries is True + assert default_log.slow_query_threshold_ms is None + assert default_log.file_path is None + assert default_log.muted_loggers == [ + "uvicorn", + "uvicorn.access", + "uvicorn.error", + "sqlalchemy.engine", + ] + assert default_log.custom_processors == [] + + def dummy_processor(logger, method, event_dict): + return event_dict + custom_log = LoggingSettings( + level="DEBUG", + json_format=True, + log_sql_queries=False, + slow_query_threshold_ms=250.0, + file_path="/var/log/zcore.log", + muted_loggers=["custom.noisy.service"], + custom_processors=[dummy_processor], + ) + assert custom_log.level == "DEBUG" + assert custom_log.json_format is True + assert custom_log.log_sql_queries is False + assert custom_log.slow_query_threshold_ms == 250.0 + assert custom_log.file_path == "/var/log/zcore.log" + assert custom_log.muted_loggers == ["custom.noisy.service"] + assert custom_log.custom_processors == [dummy_processor] + + +def test_settings_sync_validator_database_bidirectional() -> None: + settings_from_flat = Settings( + DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/syncdb", + POOL_SIZE=25, + MAX_OVERFLOW=50, + ) + assert settings_from_flat.DATABASE.url == "postgresql+asyncpg://postgres:postgres@localhost:5432/syncdb" + assert settings_from_flat.DATABASE.pool_size == 25 + assert settings_from_flat.DATABASE.max_overflow == 50 + + settings_from_nested = Settings( + DATABASE=DatabaseSettings( + url="mysql+aiomysql://root:root@localhost:3306/syncdb", + pool_size=12, + max_overflow=24, + ) + ) + assert settings_from_nested.DATABASE_URL == "mysql+aiomysql://root:root@localhost:3306/syncdb" + assert settings_from_nested.POOL_SIZE == 12 + assert settings_from_nested.MAX_OVERFLOW == 24 + + +def test_settings_sync_validator_logging_bidirectional() -> None: + settings_from_flat = Settings(LOG_LEVEL="DEBUG") + assert settings_from_flat.LOGGING.level == "DEBUG" + + settings_from_nested = Settings( + LOGGING=LoggingSettings(level="ERROR") + ) + assert settings_from_nested.LOG_LEVEL == "ERROR" + + +def test_timezone_settings_defaults() -> None: + default_settings = Settings() + assert default_settings.TIMEZONE == "UTC" + assert default_settings.AUTO_CONVERT_TIMEZONE is True + + custom_settings = Settings( + TIMEZONE="Asia/Tehran", + AUTO_CONVERT_TIMEZONE=False, + ) + assert custom_settings.TIMEZONE == "Asia/Tehran" + assert custom_settings.AUTO_CONVERT_TIMEZONE is False \ No newline at end of file diff --git a/tests/test_db_lifecycle.py b/tests/test_db_lifecycle.py index f54c481..edadfb2 100644 --- a/tests/test_db_lifecycle.py +++ b/tests/test_db_lifecycle.py @@ -1,12 +1,16 @@ +import datetime import uuid from collections.abc import AsyncGenerator from contextlib import asynccontextmanager +from decimal import Decimal from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest +from sqlalchemy import JSON, Column, Integer, select -from zcore.db.setup import DatabaseManager +from zcore.config import DatabaseSettings, settings +from zcore.db.setup import Base, DatabaseManager from zcore.db.uow import UnitOfWork @@ -26,12 +30,13 @@ async def test_db_manager_init(db_url: str, pool_size: int, max_overflow: int) - max_overflow=max_overflow, echo=False ) - + assert manager._engine is not None assert manager._session_factory is not None - + await manager.close() + @pytest.mark.parametrize( "exception_class", [ @@ -44,7 +49,7 @@ async def test_db_manager_init(db_url: str, pool_size: int, max_overflow: int) - async def test_db_session_rollback_on_error(exception_class: type[Exception]) -> None: manager = DatabaseManager() mock_session = AsyncMock() - + @asynccontextmanager async def mock_session_ctx() -> AsyncGenerator[AsyncMock, None]: yield mock_session @@ -58,6 +63,7 @@ async def mock_session_ctx() -> AsyncGenerator[AsyncMock, None]: mock_session.rollback.assert_called_once() + @pytest.mark.parametrize( "events_to_register", [ @@ -74,12 +80,12 @@ async def test_uow_commit_emits_events(events_to_register: list[tuple[str, dict[ session = AsyncMock() dispatcher = AsyncMock() uow = UnitOfWork(session, dispatcher) - + call_order: list[str] = [] - + async def track_commit() -> None: call_order.append("commit") - + async def track_dispatch(event: str, payload: Any) -> list[Any]: call_order.append(f"dispatch:{event}") return [] @@ -99,6 +105,7 @@ async def track_dispatch(event: str, payload: Any) -> list[Any]: else: assert call_order == ["commit"] + @pytest.mark.parametrize( "exception_class, events_to_register", [ @@ -134,6 +141,7 @@ async def test_uow_rollback_clears_events( dispatcher.dispatch.assert_not_called() session.rollback.assert_called() + @pytest.mark.anyio async def test_db_manager_uninitialized_access() -> None: manager = DatabaseManager() @@ -141,10 +149,11 @@ async def test_db_manager_uninitialized_access() -> None: async with manager.session(): pass + @pytest.mark.anyio async def test_db_manager_sql_logger_timing() -> None: listeners = {} - + def mock_listens_for(target, identifier): def decorator(fn): listeners[identifier] = fn @@ -153,35 +162,36 @@ def decorator(fn): with patch("sqlalchemy.event.listens_for", mock_listens_for), \ patch("structlog.get_logger") as mock_get_logger: - + mock_logger = MagicMock() mock_get_logger.return_value = mock_logger - + manager = DatabaseManager() sync_engine_mock = MagicMock() sync_engine_mock.dialect.name = "postgresql" - + manager._register_query_logger(sync_engine_mock) - + assert "before_cursor_execute" in listeners assert "after_cursor_execute" in listeners - + mock_context = MagicMock() listeners["before_cursor_execute"](None, None, "SELECT 1", {}, mock_context, False) assert hasattr(mock_context, "_query_start_time") - + listeners["after_cursor_execute"](None, None, "SELECT 1", {}, mock_context, False) mock_logger.info.assert_called_once() - + mock_logger.reset_mock() listeners["after_cursor_execute"](None, None, "SELECT * FROM pg_catalog.pg_tables", {}, mock_context, False) mock_logger.info.assert_not_called() + @pytest.mark.anyio async def test_db_manager_session_propagates_exception() -> None: manager = DatabaseManager() mock_session = AsyncMock() - + @asynccontextmanager async def mock_session_ctx() -> AsyncGenerator[AsyncMock, None]: yield mock_session @@ -191,91 +201,223 @@ async def mock_session_ctx() -> AsyncGenerator[AsyncMock, None]: with pytest.raises(ValueError, match="Internal Error"): async with manager.session(): raise ValueError("Internal Error") - + mock_session.rollback.assert_called_once() + @pytest.mark.anyio async def test_uow_event_handler_failure_gracefulness() -> None: session = AsyncMock() dispatcher = AsyncMock() uow = UnitOfWork(session, dispatcher) - + uow.register_event("e1", {}) uow.register_event("e2", {}) - + call_order = [] - + async def mock_dispatch(event: str, payload: Any) -> list[Any]: call_order.append(event) if event == "e1": raise ValueError("E1 handler failed") return [] - + dispatcher.dispatch.side_effect = mock_dispatch - + await uow.commit() - + assert call_order == ["e1", "e2"] assert len(uow._pending_events) == 0 + @pytest.mark.anyio async def test_uow_db_commit_failure() -> None: session = AsyncMock() dispatcher = AsyncMock() uow = UnitOfWork(session, dispatcher) - + session.commit.side_effect = Exception("DB error") uow.register_event("e1", {}) - + with pytest.raises(Exception, match="DB error"): await uow.commit() - + session.rollback.assert_called_once() dispatcher.dispatch.assert_not_called() assert len(uow._pending_events) == 1 + @pytest.mark.anyio async def test_uow_managed_flag() -> None: session = AsyncMock() session.info = {} dispatcher = AsyncMock() uow = UnitOfWork(session, dispatcher) - + assert session.info.get("uow_managed") is not True - + async with uow: assert session.info.get("uow_managed") is True - + assert session.info.get("uow_managed") is False + @pytest.mark.anyio async def test_uow_context_manager_auto_commit() -> None: session = AsyncMock() session.info = {} dispatcher = AsyncMock() uow = UnitOfWork(session, dispatcher) - + uow.register_event("e1", {}) - + async with uow: pass - + session.commit.assert_called_once() dispatcher.dispatch.assert_called_once_with("e1", {}) + @pytest.mark.anyio async def test_uow_context_manager_auto_rollback() -> None: session = AsyncMock() session.info = {} dispatcher = AsyncMock() uow = UnitOfWork(session, dispatcher) - + uow.register_event("e1", {}) - + with pytest.raises(ValueError, match="Err"): async with uow: raise ValueError("Err") - + session.rollback.assert_called_once() dispatcher.dispatch.assert_not_called() - assert len(uow._pending_events) == 0 \ No newline at end of file + assert len(uow._pending_events) == 0 + + +@pytest.mark.anyio +async def test_database_manager_init_with_database_settings_object() -> None: + config = DatabaseSettings( + url="sqlite+aiosqlite:///:memory:", + pool_size=10, + max_overflow=20, + pool_recycle=3600, + pool_pre_ping=True, + connect_args={"timeout": 30}, + execution_options={"isolation_level": "AUTOCOMMIT"}, + ) + manager = DatabaseManager() + manager.init_app(config=config) + assert manager._engine is not None + assert manager._session_factory is not None + await manager.close() + + +def test_database_manager_init_missing_url_raises_value_error() -> None: + manager = DatabaseManager() + with pytest.raises(ValueError, match="A valid database URL must be provided"): + manager.init_app(db_url=None, config={"url": None}) + + +def test_sql_query_logger_suppression_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + listeners = {} + + def mock_listens_for(target: Any, identifier: str) -> Any: + def decorator(fn: Any) -> Any: + listeners[identifier] = fn + return fn + return decorator + + with patch("sqlalchemy.event.listens_for", mock_listens_for), \ + patch("structlog.get_logger") as mock_get_logger: + mock_logger = MagicMock() + mock_get_logger.return_value = mock_logger + + manager = DatabaseManager() + sync_engine_mock = MagicMock() + sync_engine_mock.dialect.name = "postgresql" + manager._register_query_logger(sync_engine_mock) + + mock_context = MagicMock() + listeners["before_cursor_execute"](None, None, "SELECT 1", {}, mock_context, False) + + monkeypatch.setattr(settings.LOGGING, "log_sql_queries", False) + listeners["after_cursor_execute"](None, None, "SELECT 1", {}, mock_context, False) + mock_logger.info.assert_not_called() + + +def test_sql_query_logger_slow_query_threshold(monkeypatch: pytest.MonkeyPatch) -> None: + listeners = {} + + def mock_listens_for(target: Any, identifier: str) -> Any: + def decorator(fn: Any) -> Any: + listeners[identifier] = fn + return fn + return decorator + + with patch("sqlalchemy.event.listens_for", mock_listens_for), \ + patch("structlog.get_logger") as mock_get_logger: + mock_logger = MagicMock() + mock_get_logger.return_value = mock_logger + + manager = DatabaseManager() + sync_engine_mock = MagicMock() + sync_engine_mock.dialect.name = "postgresql" + manager._register_query_logger(sync_engine_mock) + + monkeypatch.setattr(settings.LOGGING, "log_sql_queries", True) + monkeypatch.setattr(settings.LOGGING, "slow_query_threshold_ms", 100.0) + + fast_context = MagicMock() + fast_context._query_start_time = 1000.0 + with patch("time.perf_counter", return_value=1000.01): + listeners["after_cursor_execute"](None, None, "SELECT 1", {}, fast_context, False) + mock_logger.info.assert_not_called() + + slow_context = MagicMock() + slow_context._query_start_time = 1000.0 + with patch("time.perf_counter", return_value=1000.20): + listeners["after_cursor_execute"](None, None, "SELECT 1", {}, slow_context, False) + mock_logger.info.assert_called_once() + + +@pytest.mark.anyio +async def test_database_custom_json_serializer_support() -> None: + table_name = f"json_test_{uuid.uuid4().hex[:6]}" + + class JsonTestModel(Base): + __tablename__ = table_name + id = Column(Integer, primary_key=True, autoincrement=True) + payload = Column(JSON, nullable=False) + + manager = DatabaseManager() + manager.init_app(db_url="sqlite+aiosqlite:///:memory:") + + async with manager._engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + test_uid = uuid.UUID("12345678-1234-5678-1234-567812345678") + test_decimal = Decimal("99.95") + test_dt = datetime.datetime(2026, 7, 2, 10, 0, 0) + + async with manager.session() as session: + record = JsonTestModel( + payload={ + "uid": test_uid, + "decimal": test_decimal, + "dt": test_dt, + } + ) + session.add(record) + await session.commit() + record_id = record.id + + async with manager.session() as session: + result = await session.execute(select(JsonTestModel).where(JsonTestModel.id == record_id)) + fetched = result.scalar_one() + assert fetched.payload["uid"] == str(test_uid) + assert fetched.payload["decimal"] == str(test_decimal) + assert "2026-07-02" in fetched.payload["dt"] + + await manager.close() \ No newline at end of file diff --git a/tests/test_di.py b/tests/test_di.py index e3ddf2c..c03854f 100644 --- a/tests/test_di.py +++ b/tests/test_di.py @@ -4,6 +4,7 @@ from collections.abc import Generator from contextlib import contextmanager from typing import Annotated, Any, get_args, get_origin +from unittest.mock import AsyncMock, patch import pytest from fastapi.params import Depends as DependsClass @@ -27,76 +28,95 @@ class IService: pass + class ServiceImpl(IService): def __init__(self) -> None: self.id = uuid.uuid4() + class AnotherServiceImpl(IService): def __init__(self) -> None: self.id = uuid.uuid4() + class ForwardA: def __init__(self, b: "ForwardB") -> None: self.b = b + class ForwardB: def __init__(self) -> None: self.id = uuid.uuid4() + class CircA: def __init__(self, b: "CircB") -> None: self.b = b + class CircB: def __init__(self, a: "CircA") -> None: self.a = a + class AbstractService(ABC): pass + class ConcreteService(AbstractService): def __init__(self) -> None: self.id = uuid.uuid4() + class AutoZ: def __init__(self) -> None: self.id = uuid.uuid4() + class AutoY: def __init__(self, z: AutoZ) -> None: self.z = z + class AutoX: def __init__(self, y: AutoY) -> None: self.y = y + class SimpleNoInit: pass + class DepWithAnnotated: def __init__(self, dep: Annotated[ForwardB, "meta"]) -> None: self.dep = dep + class DeepA: def __init__(self, b: "DeepB") -> None: self.b = b + class DeepB: def __init__(self, c: "DeepC") -> None: self.c = c + class DeepC: def __init__(self, a: "DeepA") -> None: self.a = a + class BackgroundTaskWorker: def __init__(self) -> None: self.id = uuid.uuid4() + class SecondaryWorker: def __init__(self) -> None: self.id = uuid.uuid4() + @contextmanager def di_scope(scope_id: str) -> Generator[None, None, None]: token_id = _current_scope_id.set(scope_id) @@ -107,6 +127,7 @@ def di_scope(scope_id: str) -> Generator[None, None, None]: _current_scope_id.reset(token_id) _scoped_instances.reset(token_instances) + @pytest.fixture(autouse=True) def reset_container() -> None: container._singletons.clear() @@ -115,6 +136,7 @@ def reset_container() -> None: container._constructor_cache.clear() container._dependency_signature_cache.clear() + @pytest.mark.parametrize( "interface, implementation", [ @@ -125,14 +147,15 @@ def reset_container() -> None: def test_resolve_singleton(interface: type[Any], implementation: type[Any]) -> None: instance = implementation() container.register_singleton(interface, instance) - + res1 = container.resolve(interface) res2 = container.resolve(interface) - + assert res1 is instance assert res2 is instance assert res1.id == res2.id + @pytest.mark.parametrize( "interface, implementation, scope_1, scope_2", [ @@ -147,16 +170,17 @@ def test_resolve_scoped( scope_2: str ) -> None: container.register_scoped(interface, implementation) - + with di_scope(scope_1): res1 = container.resolve(interface) res2 = container.resolve(interface) assert res1 is res2 - + with di_scope(scope_2): res3 = container.resolve(interface) assert res3 is not res1 + @pytest.mark.parametrize( "interface, implementation", [ @@ -166,14 +190,15 @@ def test_resolve_scoped( ) def test_resolve_transient(interface: type[Any], implementation: type[Any]) -> None: container.register_transient(interface, implementation) - + res1 = container.resolve(interface) res2 = container.resolve(interface) - + assert res1 is not res2 assert isinstance(res1, implementation) assert isinstance(res2, implementation) + @pytest.mark.parametrize( "registrations", [ @@ -183,14 +208,15 @@ def test_resolve_transient(interface: type[Any], implementation: type[Any]) -> N def test_circular_dependency(registrations: dict[str, type[Any]]) -> None: container.register_transient(CircA, registrations["A"]) container.register_transient(CircB, registrations["B"]) - + with pytest.raises(CircularDependencyError) as exc_info: container.resolve(CircA) - + assert "Circular dependency detected" in str(exc_info.value) assert "CircA" in str(exc_info.value) assert "CircB" in str(exc_info.value) + @pytest.mark.parametrize( "target, dep", [ @@ -200,16 +226,17 @@ def test_circular_dependency(registrations: dict[str, type[Any]]) -> None: def test_forward_reference_resolution(target: type[Any], dep: type[Any]) -> None: container.register_transient(dep, dep) container.register_transient(target, target) - + resolved = container.resolve(target) assert isinstance(resolved, target) assert isinstance(resolved.b, dep) + @pytest.mark.anyio @pytest.mark.parametrize("num_tasks", [10, 50]) async def test_di_concurrency_isolation(num_tasks: int) -> None: container.register_scoped(IService, ServiceImpl) - + async def run_task(task_id: str) -> tuple[str, Any]: with di_scope(task_id): res1 = container.resolve(IService) @@ -217,16 +244,17 @@ async def run_task(task_id: str) -> tuple[str, Any]: res2 = container.resolve(IService) assert res1 is res2 return task_id, res1 - + tasks = [run_task(f"scope-{uuid.uuid4()}") for _ in range(num_tasks)] results = await asyncio.gather(*tasks) - + seen_ids = set() for _task_id, instance in results: assert instance.id not in seen_ids seen_ids.add(instance.id) assert len(seen_ids) == num_tasks + def test_register_scoped_instance_success() -> None: instance = ServiceImpl() with di_scope("active_scope"): @@ -234,22 +262,26 @@ def test_register_scoped_instance_success() -> None: resolved = container.resolve(IService) assert resolved is instance + def test_register_scoped_instance_outside_scope_error() -> None: instance = ServiceImpl() with pytest.raises(DIException) as exc_info: container.register_scoped_instance(IService, instance) assert "Cannot register scoped instance outside of an active scope" in str(exc_info.value) + def test_auto_wire_implicit_resolution() -> None: resolved = container.resolve(ForwardB) assert isinstance(resolved, ForwardB) + def test_auto_wire_recursive() -> None: resolved = container.resolve(AutoX) assert isinstance(resolved, AutoX) assert isinstance(resolved.y, AutoY) assert isinstance(resolved.y.z, AutoZ) + def test_constructor_and_signature_cache() -> None: assert ForwardA not in container._constructor_cache assert ForwardA not in container._dependency_signature_cache @@ -260,6 +292,7 @@ def test_constructor_and_signature_cache() -> None: assert ForwardA in container._dependency_signature_cache assert container._dependency_signature_cache[ForwardA] == [ForwardB] + @pytest.mark.anyio async def test_injector_callable_helper() -> None: container.register_transient(IService, ServiceImpl) @@ -267,6 +300,7 @@ async def test_injector_callable_helper() -> None: resolved = await injector_instance() assert isinstance(resolved, ServiceImpl) + def test_inject_syntactic_sugar() -> None: injected = Inject[IService] assert get_origin(injected) is Annotated @@ -277,17 +311,20 @@ def test_inject_syntactic_sugar() -> None: assert isinstance(depends_dep.dependency, Injector) assert depends_dep.dependency.interface is IService + def test_interface_to_implementation_binding() -> None: container.register_transient(AbstractService, ConcreteService) resolved = container.resolve(AbstractService) assert isinstance(resolved, ConcreteService) + def test_class_no_init_or_object_init() -> None: resolved = container.resolve(SimpleNoInit) assert isinstance(resolved, SimpleNoInit) assert SimpleNoInit in container._dependency_signature_cache assert container._dependency_signature_cache[SimpleNoInit] == [] + def test_constructor_annotated_parameters() -> None: container.register_transient(ForwardB, ForwardB) container.register_transient(DepWithAnnotated, DepWithAnnotated) @@ -295,6 +332,7 @@ def test_constructor_annotated_parameters() -> None: assert isinstance(resolved, DepWithAnnotated) assert isinstance(resolved.dep, ForwardB) + def test_clear_scope_explicit() -> None: container.register_scoped(IService, ServiceImpl) scope_id = "test_clear" @@ -306,6 +344,7 @@ def test_clear_scope_explicit() -> None: res3 = container.resolve(IService) assert res3 is not res1 + def test_deep_circular_dependency() -> None: container.register_transient(DeepA, DeepA) container.register_transient(DeepB, DeepB) @@ -317,102 +356,108 @@ def test_deep_circular_dependency() -> None: assert "DeepB" in str(exc_info.value) assert "DeepC" in str(exc_info.value) + @pytest.mark.anyio async def test_background_scope_lifecycle_and_isolation() -> None: if not db_manager._engine: db_manager.init_app("sqlite+aiosqlite:///:memory:") - + parent_ctx_token = _request_context_store.set({"user_id": "parent-user", "role": "admin"}) - + try: assert _current_scope_id.get() is None - + async with background_scope(inherit_context=True, custom_flag=True): assert _current_scope_id.get() is not None session = container.resolve(AsyncSession) assert isinstance(session, AsyncSession) - + current_store = _request_context_store.get() assert current_store.get("user_id") == "parent-user" assert current_store.get("custom_flag") is True - + assert _current_scope_id.get() is None assert _request_context_store.get().get("custom_flag") is None assert _request_context_store.get().get("user_id") == "parent-user" finally: _request_context_store.reset(parent_ctx_token) + @pytest.mark.anyio async def test_background_scope_no_inherit_context() -> None: if not db_manager._engine: db_manager.init_app("sqlite+aiosqlite:///:memory:") parent_ctx_token = _request_context_store.set({"user_id": "isolated-user", "tenant": "corp"}) - + try: async with background_scope(inherit_context=False, task_only_key="task-value"): current_store = _request_context_store.get() assert current_store.get("user_id") is None assert current_store.get("tenant") is None assert current_store.get("task_only_key") == "task-value" - + assert _request_context_store.get().get("user_id") == "isolated-user" assert _request_context_store.get().get("tenant") == "corp" finally: _request_context_store.reset(parent_ctx_token) + @pytest.mark.anyio async def test_background_scope_exception_cleanup_guarantee() -> None: if not db_manager._engine: db_manager.init_app("sqlite+aiosqlite:///:memory:") token = _request_context_store.set({"initial": "state"}) - + try: with pytest.raises(ValueError, match="Scope breakdown"): async with background_scope(inherit_context=True, err_state=True): assert _current_scope_id.get() is not None raise ValueError("Scope breakdown") - + assert _current_scope_id.get() is None assert _request_context_store.get() == {"initial": "state"} finally: _request_context_store.reset(token) + @pytest.mark.anyio async def test_background_task_decorator_async_execution() -> None: if not db_manager._engine: db_manager.init_app("sqlite+aiosqlite:///:memory:") - + container.register_scoped(BackgroundTaskWorker, BackgroundTaskWorker) - + @background_task async def sample_async_job(target_name: str, worker: BackgroundTaskWorker) -> dict[str, Any]: session = container.resolve(AsyncSession) assert isinstance(session, AsyncSession) return {"name": target_name, "worker_id": worker.id} - + result = await sample_async_job("task-alpha") assert result["name"] == "task-alpha" assert isinstance(result["worker_id"], uuid.UUID) + @pytest.mark.anyio async def test_background_task_decorator_sync_execution() -> None: if not db_manager._engine: db_manager.init_app("sqlite+aiosqlite:///:memory:") - + container.register_scoped(BackgroundTaskWorker, BackgroundTaskWorker) - + @background_task def sample_sync_job(value: int, worker: BackgroundTaskWorker) -> int: session = container.resolve(AsyncSession) assert isinstance(session, AsyncSession) assert isinstance(worker.id, uuid.UUID) return value * 2 - + computed = await sample_sync_job(21) assert computed == 42 + @pytest.mark.anyio async def test_background_task_explicit_override_arguments() -> None: if not db_manager._engine: @@ -428,6 +473,7 @@ async def override_job(worker: BackgroundTaskWorker) -> uuid.UUID: result_id = await override_job(worker=explicit_worker) assert result_id == explicit_worker.id + @pytest.mark.anyio async def test_background_task_multiple_injections() -> None: if not db_manager._engine: @@ -449,6 +495,7 @@ async def multi_dep_job( assert isinstance(id2, uuid.UUID) assert tag == "default_tag" + @pytest.mark.anyio async def test_background_task_concurrent_isolation() -> None: if not db_manager._engine: @@ -469,4 +516,38 @@ async def concurrent_job(idx: int, worker: BackgroundTaskWorker) -> tuple[int, u session_memory_ids = {r[2] for r in results} assert len(worker_ids) == 10 - assert len(session_memory_ids) == 10 \ No newline at end of file + assert len(session_memory_ids) == 10 + + +@pytest.mark.anyio +async def test_background_task_warns_when_async_session_passed() -> None: + if not db_manager._engine: + db_manager.init_app("sqlite+aiosqlite:///:memory:") + + mock_session = AsyncMock(spec=AsyncSession) + + @background_task + async def async_job_with_session(session: AsyncSession, name: str) -> str: + return name + + @background_task + def sync_job_with_session(session: AsyncSession, count: int) -> int: + return count + + with patch("zcore.kernel.di.logger.warning") as mock_warn: + res_pos = await async_job_with_session(mock_session, "pos_call") + assert res_pos == "pos_call" + mock_warn.assert_called_once() + assert "Passing an existing AsyncSession directly" in mock_warn.call_args[0][0] + + with patch("zcore.kernel.di.logger.warning") as mock_warn: + res_kw = await async_job_with_session(session=mock_session, name="kw_call") + assert res_kw == "kw_call" + mock_warn.assert_called_once() + assert "Passing an existing AsyncSession directly via argument 'session'" in mock_warn.call_args[0][0] + + with patch("zcore.kernel.di.logger.warning") as mock_warn: + res_sync = await sync_job_with_session(mock_session, 100) + assert res_sync == 100 + mock_warn.assert_called_once() + assert "Passing an existing AsyncSession directly" in mock_warn.call_args[0][0] \ No newline at end of file diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index b32fdcc..f0a26c3 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -17,12 +17,14 @@ ValidationError, ) from zcore.exceptions.handlers import app_exception_handler +from zcore.utils.timezone import format_iso_with_app_timezone from zcore.web.response import ResponseWrapper class CustomPaymentFailed(AppException): status_code = 402 + @pytest.mark.parametrize( "exc_class, status_code, message, payload", [ @@ -45,6 +47,7 @@ def test_exception_status_codes( assert exc.message == message assert exc.payload == payload + @pytest.mark.anyio @pytest.mark.parametrize( "exc_to_raise, expected_status, expected_msg, expected_meta_payload", @@ -79,6 +82,7 @@ def trigger_error() -> None: assert body["meta"]["error_type"] == exc_to_raise.__class__.__name__ assert body["meta"]["payload"] == expected_meta_payload + @pytest.mark.anyio async def test_exception_logging_integration() -> None: app = FastAPI() @@ -102,6 +106,7 @@ def trigger_log() -> None: method="GET" ) + @pytest.mark.anyio async def test_custom_app_exception_subclass() -> None: app = FastAPI() @@ -121,6 +126,7 @@ def trigger_payment_error() -> None: assert body["meta"]["error_type"] == "CustomPaymentFailed" assert body["meta"]["payload"] == {"reason": "insufficient_funds"} + @pytest.mark.anyio async def test_complex_payload_serialization() -> None: app = FastAPI() @@ -140,9 +146,10 @@ def trigger_complex() -> None: assert response.status_code == 400 body = response.json() assert body["meta"]["payload"]["uuid"] == str(uid) - assert body["meta"]["payload"]["time"] == now.isoformat() + assert body["meta"]["payload"]["time"] == format_iso_with_app_timezone(now) assert body["meta"]["payload"]["decimal"] == "123.45" + @pytest.mark.anyio async def test_boundary_null_values() -> None: app = FastAPI() @@ -161,6 +168,7 @@ def trigger_null() -> None: assert body["message"] == "" assert body["meta"]["payload"] is None + @pytest.mark.anyio async def test_strict_response_wrapper_compliance() -> None: app = FastAPI() diff --git a/tests/test_kernel.py b/tests/test_kernel.py index 21490cb..fc18f7d 100644 --- a/tests/test_kernel.py +++ b/tests/test_kernel.py @@ -1,4 +1,5 @@ import uuid +from unittest.mock import AsyncMock, patch import pytest from fastapi import FastAPI @@ -10,9 +11,9 @@ class TrackedPlugin: def __init__( - self, - name: str, - dependencies: list[str], + self, + name: str, + dependencies: list[str], log: list[str], fail_phase: str | None = None ) -> None: @@ -47,6 +48,7 @@ async def on_shutdown(self) -> None: raise ValueError("on_shutdown failure") self.log.append(f"{self.name}:on_shutdown") + @pytest.mark.parametrize( "graph", [ @@ -59,15 +61,16 @@ def test_topological_sort_order(graph: dict[str, list[str]]) -> None: log: list[str] = [] for name, deps in graph.items(): kernel.add_plugin(TrackedPlugin(name, deps, log)) - + resolved = kernel._resolve_dependencies() resolved_names = [p.name for p in resolved] - + for name, deps in graph.items(): name_idx = resolved_names.index(name) for dep in deps: assert resolved_names.index(dep) < name_idx + @pytest.mark.parametrize( "graph", [ @@ -80,11 +83,12 @@ def test_cycle_dependency_error(graph: dict[str, list[str]]) -> None: log: list[str] = [] for name, deps in graph.items(): kernel.add_plugin(TrackedPlugin(name, deps, log)) - + with pytest.raises(RuntimeError) as exc_info: kernel._resolve_dependencies() assert "Cyclic dependency" in str(exc_info.value) + @pytest.mark.parametrize( "graph", [ @@ -97,11 +101,12 @@ def test_missing_dependency_error(graph: dict[str, list[str]]) -> None: log: list[str] = [] for name, deps in graph.items(): kernel.add_plugin(TrackedPlugin(name, deps, log)) - + with pytest.raises(RuntimeError) as exc_info: kernel._resolve_dependencies() assert "Missing dependency" in str(exc_info.value) + @pytest.mark.anyio @pytest.mark.parametrize( "graph", @@ -113,15 +118,15 @@ async def test_plugin_lifespan_lifecycle(graph: dict[str, list[str]]) -> None: kernel = Kernel() log: list[str] = [] app = FastAPI() - + for name, deps in graph.items(): kernel.add_plugin(TrackedPlugin(name, deps, log)) - + kernel.setup(app) - + assert log == ["A:setup", "B:setup", "C:setup"] log.clear() - + async with kernel.lifespan(app): assert log == [ "A:before_startup", "B:before_startup", "C:before_startup", @@ -129,11 +134,12 @@ async def test_plugin_lifespan_lifecycle(graph: dict[str, list[str]]) -> None: "A:after_startup", "B:after_startup", "C:after_startup", ] log.clear() - + assert log == [ "C:on_shutdown", "B:on_shutdown", "A:on_shutdown" ] + @pytest.mark.anyio async def test_empty_kernel() -> None: kernel = Kernel() @@ -143,6 +149,7 @@ async def test_empty_kernel() -> None: async with kernel.lifespan(app): pass + def test_self_dependency_error() -> None: kernel = Kernel() log: list[str] = [] @@ -151,6 +158,7 @@ def test_self_dependency_error() -> None: kernel._resolve_dependencies() assert "Cyclic dependency" in str(exc_info.value) + def test_multiple_independent_chains() -> None: kernel = Kernel() log: list[str] = [] @@ -163,6 +171,7 @@ def test_multiple_independent_chains() -> None: assert names.index("A") < names.index("B") assert names.index("C") < names.index("D") + def test_redundant_dependencies() -> None: kernel = Kernel() log: list[str] = [] @@ -173,6 +182,7 @@ def test_redundant_dependencies() -> None: names = [p.name for p in resolved] assert names == ["A", "B", "C"] + @pytest.mark.anyio async def test_exception_in_before_startup() -> None: kernel = Kernel() @@ -188,6 +198,7 @@ async def test_exception_in_before_startup() -> None: assert "B:before_startup" not in log assert "A:on_startup" not in log + @pytest.mark.anyio async def test_exception_in_on_startup() -> None: kernel = Kernel() @@ -202,6 +213,7 @@ async def test_exception_in_on_startup() -> None: assert "A:on_startup" not in log assert "A:after_startup" not in log + @pytest.mark.anyio async def test_exception_in_after_startup() -> None: kernel = Kernel() @@ -215,6 +227,7 @@ async def test_exception_in_after_startup() -> None: assert "A:on_startup" in log assert "A:after_startup" not in log + @pytest.mark.anyio async def test_shutdown_failures_robustness() -> None: kernel = Kernel() @@ -229,6 +242,7 @@ async def test_shutdown_failures_robustness() -> None: assert "B:on_shutdown" not in log assert "A:on_shutdown" not in log + def test_event_dispatcher_registration() -> None: kernel = Kernel() app = FastAPI() @@ -236,6 +250,7 @@ def test_event_dispatcher_registration() -> None: resolved_dispatcher = container.resolve(EventDispatcher) assert resolved_dispatcher is kernel.dispatcher + @pytest.mark.anyio async def test_lifespan_lazy_resolution() -> None: kernel = Kernel() @@ -245,12 +260,13 @@ async def test_lifespan_lazy_resolution() -> None: async with kernel.lifespan(app): assert "A:before_startup" in log + @pytest.mark.anyio async def test_inter_plugin_event_dispatching() -> None: kernel = Kernel() log: list[str] = [] app = FastAPI() - + class EventPlugin: def __init__(self, name: str, kernel_inst: Kernel, log_list: list[str]) -> None: self.name = name @@ -258,24 +274,45 @@ def __init__(self, name: str, kernel_inst: Kernel, log_list: list[str]) -> None: self.dependencies: list[str] = [] self.kernel = kernel_inst self.log = log_list - + def setup(self, app_inst: FastAPI) -> None: pass + async def before_startup(self) -> None: pass + async def on_startup(self) -> None: await self.kernel.dispatcher.dispatch("custom_event", "event_payload") + async def after_startup(self) -> None: pass + async def on_shutdown(self) -> None: pass - + async def handler(payload: str) -> None: log.append(payload) - + kernel.dispatcher.subscribe("custom_event", handler) kernel.add_plugin(EventPlugin("A", kernel, log)) kernel.setup(app) - + async with kernel.lifespan(app): - assert log == ["event_payload"] \ No newline at end of file + assert log == ["event_payload"] + + +@pytest.mark.anyio +async def test_kernel_lifespan_closes_db_and_cache_on_shutdown() -> None: + kernel = Kernel() + app = FastAPI() + kernel.setup(app) + + with patch("zcore.cache.base.close_cache", new_callable=AsyncMock) as mock_close_cache, \ + patch("zcore.db.setup.db_manager.close", new_callable=AsyncMock) as mock_db_close: + + async with kernel.lifespan(app): + mock_close_cache.assert_not_called() + mock_db_close.assert_not_called() + + mock_close_cache.assert_called_once() + mock_db_close.assert_called_once() \ No newline at end of file diff --git a/tests/test_logging.py b/tests/test_logging.py index 746f058..965d7ca 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -7,7 +7,7 @@ import pytest import structlog -from zcore.config import settings +from zcore.config import LoggingSettings, settings @pytest.mark.parametrize( @@ -31,39 +31,42 @@ def test_logging_format_by_environment( with patch("structlog.configure") as mock_configure: logging_config.setup_logging() mock_configure.assert_called_once() - configured_processors = mock_configure.call_args[1].get("processors", []) - assert len(configured_processors) > 0 - assert isinstance(configured_processors[-1], expected_renderer_cls) + root_logger = logging.getLogger() + assert len(root_logger.handlers) > 0 + formatter = root_logger.handlers[0].formatter + assert isinstance(formatter.processors[-1], expected_renderer_cls) + def test_suppress_third_party_duplicate_handlers(monkeypatch: pytest.MonkeyPatch) -> None: loggers = ["uvicorn", "uvicorn.access", "sqlalchemy.engine"] dummy_handlers = {name: [logging.NullHandler()] for name in loggers} - + for name in loggers: logger = logging.getLogger(name) logger.handlers = list(dummy_handlers[name]) logger.propagate = False - + import zcore.logging.config as logging_config importlib.reload(logging_config) - + with patch("structlog.configure"): logging_config.setup_logging() - + for name in loggers: logger = logging.getLogger(name) assert len(logger.handlers) == 0 assert logger.propagate is True + @pytest.mark.parametrize("rich_installed", [True, False]) def test_rich_integration_debug_mode(monkeypatch: pytest.MonkeyPatch, rich_installed: bool) -> None: monkeypatch.setattr(settings, "DEBUG", True) - + import zcore.logging.config as logging_config importlib.reload(logging_config) - + mock_install = MagicMock() - + def mock_import(name: str, *args: Any, **kwargs: Any) -> Any: if name == "rich.traceback": if rich_installed: @@ -72,87 +75,185 @@ def mock_import(name: str, *args: Any, **kwargs: Any) -> Any: return mock_module raise ImportError() return original_import(name, *args, **kwargs) - + import builtins original_import = builtins.__import__ monkeypatch.setattr(builtins, "__import__", mock_import) - + with patch("structlog.configure"): logging_config.setup_logging() - + if rich_installed: mock_install.assert_called_once_with(show_locals=False) else: mock_install.assert_not_called() + def test_shared_processors_chain_integrity(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(settings, "DEBUG", False) - + import zcore.logging.config as logging_config importlib.reload(logging_config) - + with patch("structlog.configure") as mock_configure: logging_config.setup_logging() configured_processors = mock_configure.call_args[1].get("processors", []) - + assert structlog.contextvars.merge_contextvars in configured_processors - assert structlog.processors.add_log_level in configured_processors + assert structlog.stdlib.add_log_level in configured_processors assert structlog.processors.format_exc_info in configured_processors - + assert any(isinstance(p, structlog.stdlib.PositionalArgumentsFormatter) for p in configured_processors) assert any(isinstance(p, structlog.processors.TimeStamper) for p in configured_processors) + @pytest.mark.parametrize( - "debug_flag, expected_level", + "log_level_setting, expected_level", [ - (True, logging.DEBUG), - (False, logging.INFO), + ("DEBUG", logging.DEBUG), + ("INFO", logging.INFO), + ("WARNING", logging.WARNING), ] ) def test_dynamic_log_level_configuration( monkeypatch: pytest.MonkeyPatch, - debug_flag: bool, + log_level_setting: str, expected_level: int ) -> None: - monkeypatch.setattr(settings, "DEBUG", debug_flag) - + monkeypatch.setattr(settings, "LOG_LEVEL", log_level_setting) + import zcore.logging.config as logging_config importlib.reload(logging_config) - - with patch("structlog.configure"), \ - patch("structlog.make_filtering_bound_logger") as mock_make_filter: - - logging_config.setup_logging() - mock_make_filter.assert_called_once_with(expected_level) + + with patch("structlog.configure"): + logging_config.setup_logging(log_level=log_level_setting) + assert logging.getLogger().level == expected_level + def test_log_filtering_enforcement_in_action() -> None: - filtering_bound_logger_cls = structlog.make_filtering_bound_logger(logging.INFO) - mock_underlying = MagicMock() - logger = filtering_bound_logger_cls(mock_underlying, [], {}) - + class DummyHandler(logging.Handler): + def __init__(self) -> None: + super().__init__() + self.records: list[logging.LogRecord] = [] + + def emit(self, record: logging.LogRecord) -> None: + self.records.append(record) + + logger = logging.getLogger("test_filter_logger") + logger.setLevel(logging.INFO) + logger.propagate = False + handler = DummyHandler() + logger.handlers = [handler] + logger.info("info_msg") - assert mock_underlying.info.called - - mock_underlying.reset_mock() + assert len(handler.records) == 1 + assert handler.records[0].getMessage() == "info_msg" + logger.debug("debug_msg") - assert not mock_underlying.debug.called + assert len(handler.records) == 1 + def test_contextvars_binding_verification() -> None: structlog.contextvars.clear_contextvars() structlog.contextvars.bind_contextvars(request_id="test_req_xyz") - + event_dict = structlog.contextvars.merge_contextvars(None, None, {"event": "hello"}) assert event_dict.get("request_id") == "test_req_xyz" - + structlog.contextvars.clear_contextvars() + def test_factory_and_caching_configurations(monkeypatch: pytest.MonkeyPatch) -> None: import zcore.logging.config as logging_config importlib.reload(logging_config) - + with patch("structlog.configure") as mock_configure: logging_config.setup_logging() - + kwargs = mock_configure.call_args[1] assert isinstance(kwargs.get("logger_factory"), structlog.stdlib.LoggerFactory) - assert kwargs.get("cache_logger_on_first_use") is True \ No newline at end of file + assert kwargs.get("cache_logger_on_first_use") is True + + +def test_setup_logging_with_logging_settings_object() -> None: + custom_cfg = LoggingSettings(level="DEBUG", json_format=True) + import zcore.logging.config as logging_config + importlib.reload(logging_config) + + with patch("structlog.configure"): + logging_config.setup_logging(config=custom_cfg) + root_logger = logging.getLogger() + assert root_logger.level == logging.DEBUG + formatter = root_logger.handlers[0].formatter + assert isinstance(formatter.processors[-1], structlog.processors.JSONRenderer) + + dict_cfg = {"level": "WARNING", "json_format": False} + with patch("structlog.configure"): + logging_config.setup_logging(config=dict_cfg) + root_logger = logging.getLogger() + assert root_logger.level == logging.WARNING + + +def test_setup_logging_rotating_file_handler(tmp_path: Any) -> None: + from logging.handlers import RotatingFileHandler + + log_file = tmp_path / "test_app.log" + cfg = LoggingSettings(file_path=str(log_file)) + + import zcore.logging.config as logging_config + importlib.reload(logging_config) + + with patch("structlog.configure"): + logging_config.setup_logging(config=cfg) + root_logger = logging.getLogger() + file_handlers = [h for h in root_logger.handlers if isinstance(h, RotatingFileHandler)] + assert len(file_handlers) == 1 + fh = file_handlers[0] + assert fh.maxBytes == 10 * 1024 * 1024 + assert fh.backupCount == 5 + assert fh.baseFilename == str(log_file) + + +def test_setup_logging_dict_config_override() -> None: + dict_cfg = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "level": "INFO", + } + }, + "root": { + "handlers": ["console"], + "level": "INFO", + }, + } + import zcore.logging.config as logging_config + importlib.reload(logging_config) + + with patch("logging.config.dictConfig") as mock_dict_config: + logging_config.setup_logging(dict_config=dict_cfg) + mock_dict_config.assert_called_once_with(dict_cfg) + + +def test_setup_logging_extra_handlers_and_custom_processors() -> None: + dummy_handler = logging.NullHandler() + def dummy_proc(logger, method, event_dict): + return event_dict + + import zcore.logging.config as logging_config + importlib.reload(logging_config) + + with patch("structlog.configure") as mock_configure: + logging_config.setup_logging( + custom_processors=[dummy_proc], + extra_handlers=[dummy_handler], + ) + root_logger = logging.getLogger() + assert dummy_handler in root_logger.handlers + assert dummy_handler.formatter is not None + + mock_configure.assert_called_once() + configured_procs = mock_configure.call_args[1].get("processors", []) + assert dummy_proc in configured_procs \ No newline at end of file diff --git a/tests/test_repository.py b/tests/test_repository.py index 2473443..b4a10d6 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -1,7 +1,7 @@ import uuid from collections.abc import AsyncGenerator from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest import pytest_asyncio @@ -28,18 +28,22 @@ def scope_query(cls, query: Any) -> Any: return query.where(cls.name.startswith("Active")) return query + class RepoTestCreateSchema(BaseModel): name: str description: str | None = None + class RepoTestUpdateSchema(BaseModel): name: str | None = None description: str | None = None + class RepoTestRepository(BaseRepository[RepoTestModel]): def __init__(self, db: Any) -> None: super().__init__(RepoTestModel, db) + @pytest_asyncio.fixture(autouse=True) async def setup_test_tables(test_engine: Any) -> AsyncGenerator[None, None]: async with test_engine.begin() as conn: @@ -48,6 +52,7 @@ async def setup_test_tables(test_engine: Any) -> AsyncGenerator[None, None]: async with test_engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) + @pytest.mark.anyio @pytest.mark.parametrize( "name, description", @@ -59,7 +64,7 @@ async def setup_test_tables(test_engine: Any) -> AsyncGenerator[None, None]: async def test_repo_create_and_get(db_session: Any, name: str, description: str | None) -> None: repo = RepoTestRepository(db_session) schema = RepoTestCreateSchema(name=name, description=description) - + created = await repo.create(schema) assert created.id is not None assert created.name == name @@ -69,6 +74,7 @@ async def test_repo_create_and_get(db_session: Any, name: str, description: str assert fetched_with_fields is not None assert fetched_with_fields.name == name + @pytest.mark.anyio @pytest.mark.parametrize( "schemas, expect_db_hit", @@ -89,7 +95,7 @@ async def test_repo_create_multi_empty_and_filled( expect_db_hit: bool ) -> None: repo = RepoTestRepository(db_session) - + spy_session = AsyncMock(wraps=db_session) spy_repo = RepoTestRepository(spy_session) @@ -104,6 +110,7 @@ async def test_repo_create_multi_empty_and_filled( for i, item in enumerate(results): assert item.name == schemas[i].name + @pytest.mark.anyio @pytest.mark.parametrize( "partial, expected_desc", @@ -123,6 +130,7 @@ async def test_repo_partial_update(db_session: Any, partial: bool, expected_desc assert updated.name == "Updated Name" assert updated.description == expected_desc + @pytest.mark.anyio @pytest.mark.parametrize( "non_existent_id", @@ -132,7 +140,7 @@ async def test_repo_partial_update(db_session: Any, partial: bool, expected_desc ) async def test_repo_delete_multi(db_session: Any, non_existent_id: int) -> None: repo = RepoTestRepository(db_session) - + item1 = await repo.create(RepoTestCreateSchema(name="Item 1")) item2 = await repo.create(RepoTestCreateSchema(name="Item 2")) @@ -147,6 +155,7 @@ async def test_repo_delete_multi(db_session: Any, non_existent_id: int) -> None: assert await repo.get(id=item1.id) is None assert await repo.get(id=item2.id) is None + @pytest.mark.anyio async def test_repo_exist(db_session: Any) -> None: repo = RepoTestRepository(db_session) @@ -155,6 +164,7 @@ async def test_repo_exist(db_session: Any) -> None: assert await repo.exist(RepoTestModel.name == "UniqueItem") is True assert await repo.exist(name="NonExistent") is False + @pytest.mark.anyio async def test_repo_count(db_session: Any) -> None: repo = RepoTestRepository(db_session) @@ -163,17 +173,19 @@ async def test_repo_count(db_session: Any) -> None: assert await repo.count() == 2 assert await repo.count(RepoTestModel.name == "ItemA") == 1 + @pytest.mark.anyio async def test_repo_get_by_ids_empty_and_options(db_session: Any) -> None: repo = RepoTestRepository(db_session) res = await repo.get_by_ids([]) assert res == [] - + item = await repo.create(RepoTestCreateSchema(name="Test")) res2 = await repo.get_by_ids([item.id], fields=[RepoTestModel.name]) assert len(res2) == 1 assert res2[0].name == "Test" + @pytest.mark.anyio async def test_repo_update_multi(db_session: Any) -> None: repo = RepoTestRepository(db_session) @@ -195,18 +207,20 @@ async def test_repo_update_multi(db_session: Any) -> None: assert fetched2.name == "B_New" assert fetched2.description == "D2_New" + @pytest.mark.anyio async def test_repo_delete_single(db_session: Any) -> None: repo = RepoTestRepository(db_session) item = await repo.create(RepoTestCreateSchema(name="DelMe")) - + deleted = await repo.delete(item.id) assert deleted is not None assert deleted.name == "DelMe" - + assert await repo.get(id=item.id) is None assert await repo.delete(99999) is None + @pytest.mark.anyio async def test_repo_create_multi_no_refresh(db_session: Any) -> None: repo = RepoTestRepository(db_session) @@ -218,15 +232,16 @@ async def test_repo_create_multi_no_refresh(db_session: Any) -> None: assert len(results) == 2 assert results[0].name == "NR1" + @pytest.mark.anyio async def test_repo_page_number_pagination(db_session: Any) -> None: repo = RepoTestRepository(db_session) for i in range(15): await repo.create(RepoTestCreateSchema(name=f"Item_{i:02d}")) - + params = PageNumberParams(page=2, size=5, sort_by="name", sort_order="desc") res = await repo.get_list(pagination=params) - + assert res.meta["total"] == 15 assert res.meta["page"] == 2 assert res.meta["total_pages"] == 3 @@ -235,6 +250,7 @@ async def test_repo_page_number_pagination(db_session: Any) -> None: assert len(res.data) == 5 assert res.data[0].name == "Item_09" + @pytest.mark.anyio async def test_repo_page_number_sorting_validation(db_session: Any) -> None: repo = RepoTestRepository(db_session) @@ -242,6 +258,7 @@ async def test_repo_page_number_sorting_validation(db_session: Any) -> None: with pytest.raises(ValidationError): await repo.get_list(pagination=params) + @pytest.mark.anyio async def test_repo_cursor_pagination(db_session: Any) -> None: repo = RepoTestRepository(db_session) @@ -249,24 +266,25 @@ async def test_repo_cursor_pagination(db_session: Any) -> None: for i in range(5): item = await repo.create(RepoTestCreateSchema(name=f"C_{i}")) items.append(item) - + params1 = CursorParams(size=2) res1 = await repo.get_list(pagination=params1) assert len(res1.data) == 2 assert res1.meta["has_more"] is True assert res1.meta["next_cursor"] is not None - + params2 = CursorParams(size=2, cursor=res1.meta["next_cursor"]) res2 = await repo.get_list(pagination=params2) assert len(res2.data) == 2 assert res2.meta["has_more"] is True - + params3 = CursorParams(size=2, cursor=res2.meta["next_cursor"]) res3 = await repo.get_list(pagination=params3) assert len(res3.data) == 1 assert res3.meta["has_more"] is False assert res3.meta["next_cursor"] is None + @pytest.mark.anyio async def test_repo_cursor_invalid(db_session: Any) -> None: repo = RepoTestRepository(db_session) @@ -274,13 +292,14 @@ async def test_repo_cursor_invalid(db_session: Any) -> None: with pytest.raises(ValidationError): await repo.get_list(pagination=params) + @pytest.mark.anyio async def test_repo_search_engine_complex(db_session: Any) -> None: repo = RepoTestRepository(db_session) await repo.create(RepoTestCreateSchema(name="Apple", description="Fruit")) await repo.create(RepoTestCreateSchema(name="Banana", description="Fruit")) await repo.create(RepoTestCreateSchema(name="Carrot", description="Vegetable")) - + req = SearchRequest( filters=[ FilterItem( @@ -296,38 +315,40 @@ async def test_repo_search_engine_complex(db_session: Any) -> None: assert len(res) == 1 assert res[0].name == "Banana" + @pytest.mark.anyio async def test_repo_search_security_violation(db_session: Any) -> None: repo = RepoTestRepository(db_session) - + token = ctx.initialize() table_suffix = RepoTestModel.__tablename__.split('_')[-1] ctx.restricted_fields = frozenset([ "description", f"repo_test_model_{table_suffix}.description" ]) - + req = SearchRequest( filters=[ FilterItem(field="description", op="eq", value="Fruit") ] ) - + try: with pytest.raises(ForbiddenError): await repo.search(req) finally: ctx.reset(token) + @pytest.mark.anyio async def test_repo_scoped_row_level_isolation(db_session: Any) -> None: repo = RepoTestRepository(db_session) await repo.create(RepoTestCreateSchema(name="Active_1")) await repo.create(RepoTestCreateSchema(name="Active_2")) await repo.create(RepoTestCreateSchema(name="Inactive_1")) - + assert await repo.count() == 3 - + RepoTestModel._scoped_mode = True try: assert await repo.count() == 2 @@ -335,4 +356,83 @@ async def test_repo_scoped_row_level_isolation(db_session: Any) -> None: for item in res: assert item.name.startswith("Active") finally: - RepoTestModel._scoped_mode = False \ No newline at end of file + RepoTestModel._scoped_mode = False + + +@pytest.mark.anyio +async def test_create_multi_dialect_fallback_without_returning(db_session: Any) -> None: + repo = RepoTestRepository(db_session) + schemas = [ + RepoTestCreateSchema(name="Fallback 1", description="FB 1"), + RepoTestCreateSchema(name="Fallback 2", description="FB 2"), + ] + with patch.object(db_session.bind.dialect, "insert_returning", False): + results = await repo.create_multi(schemas, refresh=True) + assert len(results) == 2 + assert results[0].name == "Fallback 1" + assert results[1].name == "Fallback 2" + assert results[0].id is not None + assert results[1].id is not None + + +@pytest.mark.anyio +async def test_delete_multi_dialect_fallback_without_returning(db_session: Any) -> None: + repo = RepoTestRepository(db_session) + item1 = await repo.create(RepoTestCreateSchema(name="DelFB 1")) + item2 = await repo.create(RepoTestCreateSchema(name="DelFB 2")) + with patch.object(db_session.bind.dialect, "delete_returning", False): + deleted = await repo.delete_multi([item1.id, item2.id, 999999]) + assert len(deleted) == 2 + deleted_ids = {d.id for d in deleted} + assert item1.id in deleted_ids + assert item2.id in deleted_ids + assert await repo.get(id=item1.id) is None + assert await repo.get(id=item2.id) is None + + +@pytest.mark.anyio +async def test_update_accepts_model_instance_directly(db_session: Any) -> None: + repo = RepoTestRepository(db_session) + created = await repo.create(RepoTestCreateSchema(name="Original", description="Original Desc")) + with patch.object(repo, "get", AsyncMock()) as mock_get: + updated = await repo.update( + target=created, + schema=RepoTestUpdateSchema(name="Updated Direct", description="Updated Desc"), + ) + mock_get.assert_not_called() + assert updated is not None + assert updated.id == created.id + assert updated.name == "Updated Direct" + assert updated.description == "Updated Desc" + + +@pytest.mark.anyio +async def test_update_multi_accepts_model_instances_as_keys(db_session: Any) -> None: + repo = RepoTestRepository(db_session) + item1 = await repo.create(RepoTestCreateSchema(name="Item1", description="D1")) + item2 = await repo.create(RepoTestCreateSchema(name="Item2", description="D2")) + + data = { + item1: RepoTestUpdateSchema(name="Item1_Upd", description="D1_Upd"), + item2: RepoTestUpdateSchema(name="Item2_Upd", description="D2_Upd"), + } + updated = await repo.update_multi(data) + assert len(updated) == 2 + fetched1 = await repo.get(id=item1.id) + fetched2 = await repo.get(id=item2.id) + assert fetched1.name == "Item1_Upd" + assert fetched1.description == "D1_Upd" + assert fetched2.name == "Item2_Upd" + assert fetched2.description == "D2_Upd" + + +@pytest.mark.anyio +async def test_delete_accepts_model_instance_directly(db_session: Any) -> None: + repo = RepoTestRepository(db_session) + item = await repo.create(RepoTestCreateSchema(name="Delete Direct")) + with patch.object(repo, "get", AsyncMock()) as mock_get: + deleted = await repo.delete(target=item) + mock_get.assert_not_called() + assert deleted is not None + assert deleted.id == item.id + assert await repo.get(id=item.id) is None \ No newline at end of file diff --git a/tests/test_search.py b/tests/test_search.py index 02b0ee0..5b81576 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -22,6 +22,7 @@ class SearchUser(Base): is_active = Column(Boolean) uid = Column(Uuid) + class SearchProfile(Base): __tablename__ = f"search_profiles_{uuid_pkg.uuid4().hex[:6]}" id = Column(Integer, primary_key=True) @@ -30,6 +31,7 @@ class SearchProfile(Base): user = relationship("SearchUser") posts = relationship("SearchPost", back_populates="profile") + class SearchPost(Base): __tablename__ = f"search_posts_{uuid_pkg.uuid4().hex[:6]}" id = Column(Integer, primary_key=True) @@ -37,6 +39,7 @@ class SearchPost(Base): title = Column(String) profile = relationship("SearchProfile", back_populates="posts") + class SearchComment(Base): __tablename__ = f"search_comments_{uuid_pkg.uuid4().hex[:6]}" id = Column(Integer, primary_key=True) @@ -44,12 +47,14 @@ class SearchComment(Base): body = Column(String) post = relationship("SearchPost") + class CustomDepthEntity(Base): __tablename__ = f"custom_depth_{uuid_pkg.uuid4().hex[:6]}" __max_search_depth__ = 5 id = Column(Integer, primary_key=True) username = Column(String) + @pytest.fixture(autouse=True) def mock_restricted_fields() -> Generator[None, None, None]: from zcore.context.context import ctx @@ -57,6 +62,7 @@ def mock_restricted_fields() -> Generator[None, None, None]: yield ctx.restricted_fields = None + @pytest_asyncio.fixture(autouse=True) async def setup_search_tables(test_engine: Any) -> AsyncGenerator[None, None]: async with test_engine.begin() as conn: @@ -65,6 +71,7 @@ async def setup_search_tables(test_engine: Any) -> AsyncGenerator[None, None]: async with test_engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) + @pytest_asyncio.fixture async def seed_data(db_session: Any) -> None: user1 = SearchUser( @@ -107,6 +114,7 @@ async def seed_data(db_session: Any) -> None: await db_session.flush() + @pytest.mark.parametrize( "op, val, expected_ids", [ @@ -134,6 +142,7 @@ async def test_search_all_operators( assert len(items) == len(expected_ids) assert {item.id for item in items} == set(expected_ids) + @pytest.mark.anyio async def test_search_startswith_operator(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -150,6 +159,7 @@ async def test_search_startswith_operator(db_session: Any, seed_data: None) -> N res_none = await db_session.execute(engine.build_base_query(req_none)) assert len(res_none.scalars().all()) == 0 + @pytest.mark.anyio async def test_search_endswith_operator(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -166,6 +176,7 @@ async def test_search_endswith_operator(db_session: Any, seed_data: None) -> Non res_st = await db_session.execute(engine.build_base_query(req_st)) assert {u.id for u in res_st.scalars().all()} == {3} + @pytest.mark.anyio async def test_search_contains_operator(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -182,6 +193,7 @@ async def test_search_contains_operator(db_session: Any, seed_data: None) -> Non res_ues = await db_session.execute(engine.build_base_query(req_ues)) assert {u.id for u in res_ues.scalars().all()} == {3} + @pytest.mark.anyio async def test_search_between_operator_numbers(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -194,6 +206,7 @@ async def test_search_between_operator_numbers(db_session: Any, seed_data: None) res_2_3 = await db_session.execute(engine.build_base_query(req_2_3)) assert {u.id for u in res_2_3.scalars().all()} == {2, 3} + @pytest.mark.anyio async def test_search_between_operator_datetime(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -210,6 +223,7 @@ async def test_search_between_operator_datetime(db_session: Any, seed_data: None res_dt = await db_session.execute(engine.build_base_query(req_dt)) assert {u.id for u in res_dt.scalars().all()} == {1, 2} + @pytest.mark.parametrize( "invalid_val", [ @@ -224,13 +238,15 @@ def test_search_between_validation_error(invalid_val: Any) -> None: req = SearchRequest(filters=[FilterItem(field="id", op="between", value=invalid_val)]) with pytest.raises(ValidationError) as exc_info: engine.build_base_query(req) - assert "expects a list of 2 elements" in str(exc_info.value) + assert "requires a list of exactly 2 items" in str(exc_info.value) + def make_nested_filter(depth: int, field_name: str = "username") -> FilterItem: if depth <= 1: return FilterItem(field=field_name, op="eq", value="guest") return FilterItem(op="and", items=[make_nested_filter(depth - 1, field_name)]) + @pytest.mark.parametrize( "depth, should_raise", [ @@ -252,6 +268,7 @@ def test_search_max_filter_depth_default(depth: int, should_raise: bool) -> None query = engine.build_base_query(request) assert query is not None + @pytest.mark.parametrize( "depth, should_raise", [ @@ -273,6 +290,7 @@ def test_search_custom_model_max_depth(depth: int, should_raise: bool) -> None: query = engine.build_base_query(request) assert query is not None + @pytest.mark.parametrize( "field_variant", [ @@ -292,6 +310,7 @@ def test_search_restricted_field_bypass(field_variant: str) -> None: engine.build_base_query(sort_req) assert "restricted" in str(exc_info.value).lower() + @pytest.mark.parametrize( "paths, expected_error, error_message", [ @@ -317,22 +336,24 @@ def test_search_include_depth_and_relation( query = engine.build_base_query(request) assert query is not None + @pytest.mark.anyio async def test_search_lt_le_ge(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) - + req_lt = SearchRequest(filters=[FilterItem(field="id", op="lt", value=2)]) res_lt = await db_session.execute(engine.build_base_query(req_lt)) assert {u.id for u in res_lt.scalars().all()} == {1} - + req_le = SearchRequest(filters=[FilterItem(field="id", op="le", value=2)]) res_le = await db_session.execute(engine.build_base_query(req_le)) assert {u.id for u in res_le.scalars().all()} == {1, 2} - + req_ge = SearchRequest(filters=[FilterItem(field="id", op="ge", value=2)]) res_ge = await db_session.execute(engine.build_base_query(req_ge)) assert {u.id for u in res_ge.scalars().all()} == {2, 3} + @pytest.mark.anyio async def test_search_in_operator(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -340,18 +361,20 @@ async def test_search_in_operator(db_session: Any, seed_data: None) -> None: res = await db_session.execute(engine.build_base_query(req)) assert {u.id for u in res.scalars().all()} == {1, 3} + @pytest.mark.anyio async def test_search_is_null_operator(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchProfile) - + req_null = SearchRequest(filters=[FilterItem(field="bio", op="is_null", value=True)]) res_null = await db_session.execute(engine.build_base_query(req_null)) assert {p.id for p in res_null.scalars().all()} == {2} - + req_not_null = SearchRequest(filters=[FilterItem(field="bio", op="is_null", value=False)]) res_not_null = await db_session.execute(engine.build_base_query(req_not_null)) assert {p.id for p in res_not_null.scalars().all()} == {1, 3} + @pytest.mark.anyio async def test_search_wildcard_escaping(db_session: Any, seed_data: None) -> None: user4 = SearchUser( @@ -366,16 +389,17 @@ async def test_search_wildcard_escaping(db_session: Any, seed_data: None) -> Non await db_session.flush() engine = SearchEngine(SearchUser) - + req_percent = SearchRequest(filters=[FilterItem(field="username", op="ilike", value="admin%")]) res_percent = await db_session.execute(engine.build_base_query(req_percent)) assert {u.id for u in res_percent.scalars().all()} == {1} - + req_underscore = SearchRequest(filters=[FilterItem(field="username", op="ilike", value="user_special")]) res_underscore = await db_session.execute(engine.build_base_query(req_underscore)) results = list(res_underscore.scalars().all()) assert {u.id for u in results} == {2} + @pytest.mark.anyio async def test_search_type_coercion_date_time(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -383,6 +407,7 @@ async def test_search_type_coercion_date_time(db_session: Any, seed_data: None) res = await db_session.execute(engine.build_base_query(req)) assert {u.id for u in res.scalars().all()} == {2} + @pytest.mark.anyio async def test_search_type_coercion_uuid(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -390,6 +415,7 @@ async def test_search_type_coercion_uuid(db_session: Any, seed_data: None) -> No res = await db_session.execute(engine.build_base_query(req)) assert {u.id for u in res.scalars().all()} == {3} + @pytest.mark.anyio async def test_search_type_coercion_bool(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -397,6 +423,7 @@ async def test_search_type_coercion_bool(db_session: Any, seed_data: None) -> No res_true = await db_session.execute(engine.build_base_query(req_true)) assert {u.id for u in res_true.scalars().all()} == {1, 3} + @pytest.mark.anyio async def test_search_relation_one_to_one(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchProfile) @@ -404,6 +431,7 @@ async def test_search_relation_one_to_one(db_session: Any, seed_data: None) -> N res = await db_session.execute(engine.build_base_query(req)) assert {p.id for p in res.scalars().all()} == {3} + @pytest.mark.anyio async def test_search_relation_one_to_many_any(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchProfile) @@ -411,6 +439,7 @@ async def test_search_relation_one_to_many_any(db_session: Any, seed_data: None) res = await db_session.execute(engine.build_base_query(req)) assert {p.id for p in res.scalars().all()} == {3} + @pytest.mark.anyio async def test_search_custom_handler(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -419,6 +448,7 @@ async def test_search_custom_handler(db_session: Any, seed_data: None) -> None: res = await db_session.execute(engine.build_base_query(req)) assert {u.id for u in res.scalars().all()} == {3} + def test_search_nested_restricted_field() -> None: from zcore.context.context import ctx ctx.restricted_fields = {"post.profile.user.password"} @@ -428,6 +458,7 @@ def test_search_nested_restricted_field() -> None: engine.build_base_query(req) assert "restricted" in str(exc_info.value).lower() + def test_search_restricted_relationship_path() -> None: from zcore.context.context import ctx ctx.restricted_fields = {"SearchComment.post", "post"} @@ -437,6 +468,7 @@ def test_search_restricted_relationship_path() -> None: engine.build_base_query(req) assert "restricted" in str(exc_info.value).lower() + def test_search_invalid_sort_fields() -> None: engine = SearchEngine(SearchUser) req = SearchRequest(sort=[SortItem(field="non_existent_field", order="asc")]) @@ -444,6 +476,7 @@ def test_search_invalid_sort_fields() -> None: engine.build_base_query(req) assert "invalid sort field" in str(exc_info.value).lower() + @pytest.mark.anyio async def test_search_pagination_build_query(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -453,6 +486,7 @@ async def test_search_pagination_build_query(db_session: Any, seed_data: None) - items = list(res.scalars().all()) assert len(items) == 1 + @pytest.mark.anyio async def test_search_empty_request(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -462,6 +496,7 @@ async def test_search_empty_request(db_session: Any, seed_data: None) -> None: items = list(res.scalars().all()) assert len(items) == 3 + @pytest.mark.anyio async def test_search_complex_combined_logical_filters(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -480,4 +515,68 @@ async def test_search_complex_combined_logical_filters(db_session: Any, seed_dat ) req = SearchRequest(filters=[filter_item]) res = await db_session.execute(engine.build_base_query(req)) - assert {u.id for u in res.scalars().all()} == {1, 3} \ No newline at end of file + assert {u.id for u in res.scalars().all()} == {1, 3} + + +@pytest.mark.anyio +async def test_search_type_coercion_set_and_iterables(db_session: Any, seed_data: None) -> None: + engine = SearchEngine(SearchUser) + req_set = SearchRequest(filters=[FilterItem(field="id", op="in", value={1, 2})]) + res_set = await db_session.execute(engine.build_base_query(req_set)) + assert {u.id for u in res_set.scalars().all()} == {1, 2} + + req_set_str = SearchRequest(filters=[FilterItem(field="id", op="in", value={"1", "3"})]) + res_set_str = await db_session.execute(engine.build_base_query(req_set_str)) + assert {u.id for u in res_set_str.scalars().all()} == {1, 3} + + +@pytest.mark.anyio +async def test_search_text_operators_on_non_string_columns_cast(db_session: Any, seed_data: None) -> None: + engine = SearchEngine(SearchUser) + + req_ilike = SearchRequest(filters=[FilterItem(field="id", op="ilike", value="1")]) + res_ilike = await db_session.execute(engine.build_base_query(req_ilike)) + assert {u.id for u in res_ilike.scalars().all()} == {1} + + req_startswith = SearchRequest(filters=[FilterItem(field="uid", op="startswith", value="22222222")]) + res_startswith = await db_session.execute(engine.build_base_query(req_startswith)) + assert {u.id for u in res_startswith.scalars().all()} == {2} + + req_endswith = SearchRequest(filters=[FilterItem(field="uid", op="endswith", value="333333333333")]) + res_endswith = await db_session.execute(engine.build_base_query(req_endswith)) + assert {u.id for u in res_endswith.scalars().all()} == {3} + + req_contains = SearchRequest(filters=[FilterItem(field="uid", op="contains", value="11111111")]) + res_contains = await db_session.execute(engine.build_base_query(req_contains)) + assert {u.id for u in res_contains.scalars().all()} == {1} + + +def test_search_column_without_python_type_fallback() -> None: + engine = SearchEngine(SearchUser) + + class CustomNoPythonType: + @property + def python_type(self) -> Any: + raise NotImplementedError + + class MockColumn: + key = "unsupported_col" + type = CustomNoPythonType() + + mock_col = MockColumn() + result = engine._coerce_value(mock_col, "raw_uncoerced_value") + assert result == "raw_uncoerced_value" + + +@pytest.mark.anyio +async def test_search_numeric_coercion_from_string(db_session: Any, seed_data: None) -> None: + engine = SearchEngine(SearchUser) + + req_valid = SearchRequest(filters=[FilterItem(field="id", op="eq", value="2")]) + res_valid = await db_session.execute(engine.build_base_query(req_valid)) + assert {u.id for u in res_valid.scalars().all()} == {2} + + with pytest.raises(ValidationError) as exc_info: + req_invalid = SearchRequest(filters=[FilterItem(field="id", op="eq", value="not_a_number")]) + engine.build_base_query(req_invalid) + assert "failed to coerce" in str(exc_info.value).lower() \ No newline at end of file diff --git a/tests/test_service.py b/tests/test_service.py index a91f5a8..4cc2405 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -19,12 +19,15 @@ class ServiceTestModel(Base): id = Column(Integer, primary_key=True) name = Column(String) + class ServiceTestCreateSchema(BaseModel): name: str + class ServiceTestUpdateSchema(BaseModel): name: str + class HookTrackingService(BaseService[ServiceTestModel]): def __init__(self, model: type[ServiceTestModel], repository: Any) -> None: super().__init__(model, repository) @@ -62,7 +65,7 @@ async def post_create_multi(self, models: Sequence[ServiceTestModel]) -> None: for m in models: m.name = m.name + "_BULKAUDITED" - async def pre_update(self, id: Any, schema: ServiceTestUpdateSchema, partial: bool) -> dict[str, Any] | None: + async def pre_update(self, target: ServiceTestModel | Any, schema: ServiceTestUpdateSchema, partial: bool) -> dict[str, Any] | None: self.hooks_called.append("pre_update") schema.name = "UPDATED_HOOK_" + schema.name return None @@ -71,7 +74,7 @@ async def post_update(self, model: ServiceTestModel) -> None: self.hooks_called.append("post_update") model.name = model.name + "_UPDATED" - async def pre_update_multi(self, data: dict[Any, BaseModel], partial: bool) -> None: + async def pre_update_multi(self, data: dict[ServiceTestModel | Any, BaseModel], partial: bool) -> None: self.hooks_called.append("pre_update_multi") for _k, v in data.items(): v.name = "BULK_UPD_" + v.name @@ -81,7 +84,7 @@ async def post_update_multi(self, models: Sequence[ServiceTestModel]) -> None: for m in models: m.name = m.name + "_BULKUPDATED" - async def pre_delete(self, id: Any) -> None: + async def pre_delete(self, target: Any) -> None: self.hooks_called.append("pre_delete") async def post_delete(self, model: ServiceTestModel) -> None: @@ -104,6 +107,7 @@ async def post_search(self, models: Sequence[ServiceTestModel]) -> None: for m in models: m.name = m.name + "_SEARCHED" + @pytest.mark.anyio @pytest.mark.parametrize( "initial_name, expected_repo_name, expected_final_name", @@ -138,6 +142,7 @@ async def fake_create(schema: ServiceTestCreateSchema, **extra: Any) -> ServiceT assert result.name == expected_final_name mock_repo.create.assert_called_once() + @pytest.mark.anyio @pytest.mark.parametrize( "uow_managed, should_commit, raise_commit_error", @@ -184,6 +189,7 @@ async def test_service_safe_commit_scenarios( mock_db.commit.assert_not_called() mock_db.rollback.assert_not_called() + @pytest.mark.anyio async def test_service_post_get_hook() -> None: mock_repo = AsyncMock() @@ -196,6 +202,7 @@ async def test_service_post_get_hook() -> None: assert "post_get" in service.hooks_called assert result.name == "Database_GETHOOKED" + @pytest.mark.anyio async def test_service_post_get_multi_hook() -> None: mock_repo = AsyncMock() @@ -209,6 +216,7 @@ async def test_service_post_get_multi_hook() -> None: assert results[0].name == "R1_GETMULTIHOCKED" assert results[1].name == "R2_GETMULTIHOCKED" + @pytest.mark.anyio async def test_service_get_entity_not_found() -> None: mock_repo = AsyncMock() @@ -220,6 +228,7 @@ async def test_service_get_entity_not_found() -> None: assert "post_get" not in service.hooks_called + @pytest.mark.anyio async def test_service_get_list_pagination() -> None: mock_repo = AsyncMock() @@ -235,6 +244,7 @@ async def test_service_get_list_pagination() -> None: assert result.data[1].name == "R2_GETMULTIHOCKED" assert result.meta["page"] == 1 + @pytest.mark.anyio async def test_service_pre_create_extra_data_merge() -> None: mock_db = MagicMock() @@ -255,6 +265,7 @@ async def fake_create(schema: ServiceTestCreateSchema, **extra: Any) -> ServiceT assert "pre_create" in service.hooks_called + @pytest.mark.anyio async def test_service_create_multi_hooks() -> None: mock_db = MagicMock() @@ -276,6 +287,7 @@ async def fake_create_multi(schemas: list[BaseModel], refresh: bool = False) -> assert results[0].name == "BULK_S1_BULKAUDITED" assert results[1].name == "BULK_S2_BULKAUDITED" + @pytest.mark.anyio async def test_service_update_multi_hooks() -> None: mock_db = MagicMock() @@ -283,8 +295,8 @@ async def test_service_update_multi_hooks() -> None: mock_repo = AsyncMock() mock_repo.db = mock_db - async def fake_update_multi(data: dict[Any, BaseModel], partial: bool = False, refresh: bool = False) -> Sequence[ServiceTestModel]: - return [ServiceTestModel(id=k, name=v.name) for k, v in data.items()] + async def fake_update_multi(data: dict[ServiceTestModel | Any, BaseModel], partial: bool = False, refresh: bool = False) -> Sequence[ServiceTestModel]: + return [ServiceTestModel(id=getattr(k, "id", k), name=v.name) for k, v in data.items()] mock_repo.update_multi.side_effect = fake_update_multi @@ -300,6 +312,7 @@ async def fake_update_multi(data: dict[Any, BaseModel], partial: bool = False, r assert results[0].name == "BULK_UPD_U1_BULKUPDATED" assert results[1].name == "BULK_UPD_U2_BULKUPDATED" + @pytest.mark.anyio async def test_service_update_entity_not_found() -> None: mock_repo = AsyncMock() @@ -309,10 +322,11 @@ async def test_service_update_entity_not_found() -> None: schema = ServiceTestUpdateSchema(name="Fresh") with pytest.raises(EntityNotFound): - await service.update(id=999, schema=schema) + await service.update(target=999, schema=schema) assert "post_update" not in service.hooks_called + @pytest.mark.anyio async def test_service_delete_hooks() -> None: mock_db = MagicMock() @@ -324,12 +338,13 @@ async def test_service_delete_hooks() -> None: mock_repo.delete.return_value = record service = HookTrackingService(ServiceTestModel, mock_repo) - result = await service.delete(id=1) + result = await service.delete(target=1) assert "pre_delete" in service.hooks_called assert "post_delete" in service.hooks_called assert result.name == "ToKill_DELETED" + @pytest.mark.anyio async def test_service_delete_multi_hooks() -> None: mock_db = MagicMock() @@ -348,6 +363,7 @@ async def test_service_delete_multi_hooks() -> None: assert results[0].name == "K1_BULKDELETED" assert results[1].name == "K2_BULKDELETED" + @pytest.mark.anyio async def test_service_delete_entity_not_found() -> None: mock_repo = AsyncMock() @@ -356,10 +372,11 @@ async def test_service_delete_entity_not_found() -> None: service = HookTrackingService(ServiceTestModel, mock_repo) with pytest.raises(EntityNotFound): - await service.delete(id=999) + await service.delete(target=999) assert "post_delete" not in service.hooks_called + @pytest.mark.anyio async def test_service_search_hooks() -> None: mock_repo = AsyncMock() @@ -375,6 +392,7 @@ async def test_service_search_hooks() -> None: assert results[0].name == "Found1_SEARCHED" assert results[1].name == "Found2_SEARCHED" + @pytest.mark.anyio async def test_service_search_pagination() -> None: mock_repo = AsyncMock() @@ -392,6 +410,7 @@ async def test_service_search_pagination() -> None: assert result.data[1].name == "Found2_SEARCHED" assert result.meta["page"] == 1 + @pytest.mark.anyio async def test_service_post_create_error_rollback() -> None: mock_db = AsyncMock() diff --git a/tests/test_storage.py b/tests/test_storage.py index 1ac662e..1c0e5ee 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -18,6 +18,7 @@ def create_mock_upload_file(content: bytes, filename: str, size: int | None = None) -> UploadFile: + """Helper creating an in-memory UploadFile instance for testing.""" file_obj = BytesIO(content) upload_file = UploadFile(file=file_obj, filename=filename) if size is not None: @@ -27,6 +28,7 @@ def create_mock_upload_file(content: bytes, filename: str, size: int | None = No delattr(upload_file, "size") return upload_file + @pytest.mark.anyio @pytest.mark.parametrize( "malicious_folder", @@ -34,12 +36,13 @@ def create_mock_upload_file(content: bytes, filename: str, size: int | None = No "../", "../../etc", "..\\..\\", - ] + ], ) async def test_storage_path_traversal_prevention(test_storage_dir: str, malicious_folder: str) -> None: + """Ensure path traversal attempts are blocked during upload and deletion.""" provider = LocalStorageProvider(base_path=test_storage_dir) file = create_mock_upload_file(b"test data", "malicious.txt") - + with pytest.raises(AppException) as exc_info: await provider.upload(file, malicious_folder) assert "Path traversal attempt detected" in str(exc_info.value) @@ -54,6 +57,7 @@ async def fake_stream() -> AsyncGenerator[bytes, None]: delete_success = await provider.delete(f"{test_storage_dir}/{malicious_folder}/target.txt") assert delete_success is False + @pytest.mark.parametrize( "content, filename, allowed_mimes, should_pass, error_message", [ @@ -63,18 +67,19 @@ async def fake_stream() -> AsyncGenerator[bytes, None]: (b"", "evil.jpg", ["image/jpeg"], False, "Security policy violation"), (b"MZ\x90\x00\x03\x00\x00\x00", "payload.jpg", ["image/jpeg"], False, "Security policy violation"), (b"#!/bin/sh\nrm -rf /", "exploit.jpg", ["image/jpeg"], False, "Security policy violation"), - ] + ], ) def test_validator_mime_magic_bytes( content: bytes, filename: str, allowed_mimes: list[str], should_pass: bool, - error_message: str + error_message: str, ) -> None: + """Verify MIME type and active content detection via magic bytes.""" validator = SafeMimeTypeValidator(allowed_mimes=allowed_mimes) file = create_mock_upload_file(content, filename) - + if should_pass: validator(file) else: @@ -82,6 +87,7 @@ def test_validator_mime_magic_bytes( validator(file) assert error_message in str(exc_info.value) + @pytest.mark.parametrize( "max_size_mb, size_property, content_len, should_pass", [ @@ -89,19 +95,20 @@ def test_validator_mime_magic_bytes( (1.0, 2 * 1024 * 1024, b"dummy", False), (1.0, None, 500 * 1024, True), (1.0, None, 2 * 1024 * 1024, False), - ] + ], ) def test_validator_max_file_size( max_size_mb: float, size_property: int | None, content_len: int, - should_pass: bool + should_pass: bool, ) -> None: + """Verify maximum file size constraints.""" validator = MaxFileSizeValidator(max_size_mb=max_size_mb) content = b"x" * content_len if size_property is None else b"short" - + file = create_mock_upload_file(content, "test.bin", size=size_property) - + if should_pass: validator(file) else: @@ -109,62 +116,120 @@ def test_validator_max_file_size( validator(file) assert "exceeds the limit" in str(exc_info.value) + @pytest.mark.anyio async def test_storage_successful_upload(test_storage_dir: str) -> None: + """Verify successful file upload, URL generation, and content persistence.""" provider = LocalStorageProvider(base_path=test_storage_dir) file = create_mock_upload_file(b"test data content", "hello.txt") - path = await provider.upload(file, "text_files") - assert os.path.exists(path) - with open(path, "rb") as f: + url = await provider.upload(file, "text_files") + + assert url.startswith("/storage/text_files/") + assert await provider.exists(url) is True + + physical_path = provider._resolve_path(url) + assert physical_path is not None + assert os.path.exists(physical_path) + with open(physical_path, "rb") as f: assert f.read() == b"test data content" + @pytest.mark.anyio async def test_storage_successful_upload_stream(test_storage_dir: str) -> None: + """Verify successful binary streaming upload.""" provider = LocalStorageProvider(base_path=test_storage_dir) + async def fake_stream() -> AsyncGenerator[bytes, None]: yield b"chunk_one_" yield b"chunk_two" - path = await provider.upload_stream(fake_stream(), "streamed.bin", "streams") - assert os.path.exists(path) - with open(path, "rb") as f: + + url = await provider.upload_stream(fake_stream(), "streamed.bin", "streams") + + assert url.startswith("/storage/streams/") + assert await provider.exists(url) is True + + physical_path = provider._resolve_path(url) + assert physical_path is not None + assert os.path.exists(physical_path) + with open(physical_path, "rb") as f: assert f.read() == b"chunk_one_chunk_two" + @pytest.mark.anyio async def test_storage_collision_prevention(test_storage_dir: str) -> None: + """Verify collision prevention generates unique keys for identically named files.""" provider = LocalStorageProvider(base_path=test_storage_dir) f1 = create_mock_upload_file(b"content 1", "test.txt") f2 = create_mock_upload_file(b"content 2", "test.txt") - p1 = await provider.upload(f1, "collision") - p2 = await provider.upload(f2, "collision") - assert p1 != p2 - assert os.path.exists(p1) - assert os.path.exists(p2) + + u1 = await provider.upload(f1, "collision") + u2 = await provider.upload(f2, "collision") + + assert u1 != u2 + assert await provider.exists(u1) is True + assert await provider.exists(u2) is True + @pytest.mark.anyio async def test_storage_auto_directory_creation(test_storage_dir: str) -> None: + """Verify automatic creation of nested directory structures upon upload.""" provider = LocalStorageProvider(base_path=test_storage_dir) f = create_mock_upload_file(b"data", "test.txt") - path = await provider.upload(f, "new/nested/folder") - assert os.path.exists(path) + url = await provider.upload(f, "new/nested/folder") + + assert await provider.exists(url) is True + physical_path = provider._resolve_path(url) + assert physical_path is not None + assert os.path.exists(physical_path) + @pytest.mark.anyio async def test_storage_successful_delete(test_storage_dir: str) -> None: + """Verify file deletion and corresponding existence checks.""" provider = LocalStorageProvider(base_path=test_storage_dir) f = create_mock_upload_file(b"data", "test.txt") - path = await provider.upload(f, "docs") - assert os.path.exists(path) - res = await provider.delete(path) + url = await provider.upload(f, "docs") + + assert await provider.exists(url) is True + res = await provider.delete(url) assert res is True - assert not os.path.exists(path) + assert await provider.exists(url) is False + @pytest.mark.anyio async def test_storage_delete_non_existent(test_storage_dir: str) -> None: + """Verify idempotent deletion of non-existent files inside storage boundary.""" provider = LocalStorageProvider(base_path=test_storage_dir) fake_path = os.path.join(test_storage_dir, "docs", "missing.txt") res = await provider.delete(fake_path) assert res is True + +@pytest.mark.anyio +async def test_storage_get_url_and_exists_variants(test_storage_dir: str) -> None: + """Verify URL resolution and existence checks across URL, key, and filesystem formats.""" + provider = LocalStorageProvider(base_path=test_storage_dir, url_prefix="/media") + file = create_mock_upload_file(b"custom url test", "doc.txt") + url = await provider.upload(file, "records") + + assert url.startswith("/media/records/") + assert await provider.exists(url) is True + + key = provider._extract_key(url) + assert key is not None + assert await provider.exists(key) is True + assert await provider.get_url(key) == url + + physical_path = str(provider._key_to_path(key)) + assert await provider.exists(physical_path) is True + assert await provider.get_url(physical_path) == url + + assert await provider.exists("") is False + assert await provider.get_url("") == "" + + def test_validator_file_extension_case_insensitive() -> None: + """Verify case-insensitive matching for allowed file extensions.""" validator = FileExtensionValidator(allowed_extensions=["png", "JPEG"]) f1 = create_mock_upload_file(b"", "image.PNG") f2 = create_mock_upload_file(b"", "photo.jpeg") @@ -173,19 +238,25 @@ def test_validator_file_extension_case_insensitive() -> None: validator(f2) validator(f3) + def test_validator_file_extension_blocked() -> None: + """Verify disallowed extensions trigger a validation error.""" validator = FileExtensionValidator(allowed_extensions=["pdf"]) f = create_mock_upload_file(b"", "script.py") with pytest.raises(ValidationError): validator(f) + def test_validator_file_extension_missing() -> None: + """Verify missing file extensions trigger a validation error.""" validator = FileExtensionValidator(allowed_extensions=["pdf"]) f = create_mock_upload_file(b"", "config") with pytest.raises(ValidationError): validator(f) + def test_validator_file_extension_double_extension_attack() -> None: + """Verify trailing executable extensions in double-extension filenames are blocked.""" validator = FileExtensionValidator(allowed_extensions=["pdf", "png"]) f1 = create_mock_upload_file(b"", "doc.pdf.exe") f2 = create_mock_upload_file(b"", "avatar.png.php") @@ -194,38 +265,51 @@ def test_validator_file_extension_double_extension_attack() -> None: with pytest.raises(ValidationError): validator(f2) + def test_validator_mime_fallback() -> None: + """Verify fallback to standard mimetype guessing for safe text-based payloads.""" validator = SafeMimeTypeValidator(allowed_mimes=["application/json", "text/plain"]) f1 = create_mock_upload_file(b'{"key": "value"}', "data.json") f2 = create_mock_upload_file(b"plain text", "note.txt") validator(f1) validator(f2) + def test_validator_mime_read_error() -> None: + """Verify hardware read errors during inspection raise a ValidationError.""" validator = SafeMimeTypeValidator(allowed_mimes=["image/png"]) + class ErrorFile: def read(self, *args, **kwargs): raise OSError("Hardware read error") + def seek(self, *args, **kwargs): pass + upload_file = UploadFile(file=ErrorFile(), filename="test.png") with pytest.raises(ValidationError) as exc_info: validator(upload_file) assert "Failed to validate file signatures." in str(exc_info.value) + @pytest.mark.anyio async def test_storage_os_permission_error(test_storage_dir: str, monkeypatch: pytest.MonkeyPatch) -> None: + """Verify underlying OS write errors are cleanly caught and wrapped in AppException.""" provider = LocalStorageProvider(base_path=test_storage_dir) file = create_mock_upload_file(b"test data", "permission.txt") + def mock_open(*args, **kwargs): raise OSError("Mock disk error") + monkeypatch.setattr(aiofiles, "open", mock_open) with pytest.raises(AppException) as exc_info: await provider.upload(file, "uploads") assert "Error saving file" in str(exc_info.value) + @pytest.mark.anyio async def test_storage_provider_dependency(test_storage_dir: str) -> None: + """Verify resolution of the StorageProvider dependency injection stub.""" provider_instance = LocalStorageProvider(base_path=test_storage_dir) container.register_singleton(StorageProvider, provider_instance) resolved = await get_storage_provider(provider_instance) diff --git a/tests/test_utils.py b/tests/test_utils.py index ee9a401..b62d4d9 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,20 +2,33 @@ import uuid from decimal import Decimal from typing import Any +from unittest.mock import patch import pytest +from pydantic import BaseModel +from zcore.config import settings from zcore.exceptions.base import ValidationError from zcore.utils.helpers import json_dumps, json_loads, slugify +from zcore.utils.timezone import ( + ZDateTime, + format_iso_with_app_timezone, + get_app_timezone, + now, + to_app_timezone, + utc_now, +) from zcore.utils.validators import validate_json_schema class DummyUser: def __init__(self, name: str) -> None: self.name = name + def __str__(self) -> str: return f"DummyUser({self.name})" + @pytest.mark.parametrize( "input_val, expected_output", [ @@ -25,7 +38,7 @@ def __str__(self) -> str: ), ( datetime.datetime(2026, 7, 2, 10, 0, 0), - '"2026-07-02T10:00:00"' + '"2026-07-02T10:00:00+00:00"' ), ( datetime.date(2026, 7, 2), @@ -49,11 +62,13 @@ def test_custom_json_encoder(input_val: Any, expected_output: str) -> None: serialized = json_dumps(input_val) assert json_loads(serialized) == json_loads(expected_output) + def test_custom_json_encoder_fallback_on_custom_object() -> None: user = DummyUser("Alice") serialized = json_dumps(user) assert json_loads(serialized) == "DummyUser(Alice)" + def test_custom_json_encoder_nested_mixed_types() -> None: data = { "list": [ @@ -72,19 +87,22 @@ def test_custom_json_encoder_nested_mixed_types() -> None: assert deserialized["list"][1] == "2026-07-02" assert deserialized["list"][2] == "10.50" assert deserialized["nested"]["time"] == "14:30:00" - assert deserialized["nested"]["dt"] == "2026-07-02T12:00:00" + assert deserialized["nested"]["dt"] == "2026-07-02T12:00:00+00:00" + def test_custom_json_encoder_decimal_precision() -> None: small_decimal = Decimal("0.00000000000000000001") serialized = json_dumps(small_decimal) assert json_loads(serialized) == str(small_decimal) + def test_custom_json_encoder_circular_reference() -> None: data: dict[str, Any] = {} data["self"] = data with pytest.raises(ValueError): json_dumps(data) + def test_custom_json_encoder_kwargs() -> None: data = {"a": 1, "b": 2} serialized = json_dumps(data, indent=4) @@ -92,6 +110,7 @@ def test_custom_json_encoder_kwargs() -> None: deserialized = json_loads(serialized, parse_float=Decimal) assert deserialized == data + @pytest.mark.parametrize( "text, expected_slug", [ @@ -105,19 +124,24 @@ def test_custom_json_encoder_kwargs() -> None: def test_slugify_logic(text: str, expected_slug: str) -> None: assert slugify(text) == expected_slug + def test_slugify_unicode_persian() -> None: assert slugify("بخش مقالات ZCore 2026!") == "بخش-مقالات-zcore-2026" + @pytest.mark.parametrize("text", ["", " ", " \n\t\r "]) def test_slugify_empty_whitespaces(text: str) -> None: assert slugify(text) == "" + def test_slugify_newlines() -> None: assert slugify("First\nSecond\rThird") == "first-second-third" + def test_slugify_multiple_separators() -> None: assert slugify("hello____world----test") == "hello-world-test" + @pytest.mark.parametrize( "data, schema, should_pass, expected_error_msg", [ @@ -166,6 +190,7 @@ def test_validate_json_schema( validate_json_schema(data, schema) assert expected_error_msg in str(exc_info.value) + def test_validate_json_schema_detailed_payload() -> None: schema = { "type": "object", @@ -186,6 +211,7 @@ def test_validate_json_schema_detailed_payload() -> None: assert payload["path"] == ["user", "age"] assert "type" in payload["schema"] + def test_validate_json_schema_corrupt_schema_payload() -> None: corrupt_schema = {"type": "invalid_type_definition"} with pytest.raises(ValidationError) as exc_info: @@ -194,6 +220,7 @@ def test_validate_json_schema_corrupt_schema_payload() -> None: assert payload is not None assert "error" in payload + def test_validate_json_schema_array_constraints() -> None: schema = { "type": "object", @@ -215,6 +242,7 @@ def test_validate_json_schema_array_constraints() -> None: with pytest.raises(ValidationError): validate_json_schema(invalid_data_len, schema) + def test_validate_json_schema_additional_properties() -> None: schema = { "type": "object", @@ -227,4 +255,118 @@ def test_validate_json_schema_additional_properties() -> None: validate_json_schema(valid_data, schema) invalid_data = {"id": 1, "extra": "forbidden"} with pytest.raises(ValidationError): - validate_json_schema(invalid_data, schema) \ No newline at end of file + validate_json_schema(invalid_data, schema) + + +def test_get_app_timezone_valid_and_cached(monkeypatch: pytest.MonkeyPatch) -> None: + get_app_timezone.cache_clear() + monkeypatch.setattr(settings, "TIMEZONE", "UTC") + assert get_app_timezone() == datetime.UTC + + tehran_tz = datetime.timezone(datetime.timedelta(hours=3, minutes=30), name="Asia/Tehran") + monkeypatch.setattr("zoneinfo.ZoneInfo", lambda name: tehran_tz) + get_app_timezone.cache_clear() + monkeypatch.setattr(settings, "TIMEZONE", "Asia/Tehran") + tz = get_app_timezone() + assert str(tz) == "Asia/Tehran" + + info_before = get_app_timezone.cache_info() + cached_tz = get_app_timezone() + info_after = get_app_timezone.cache_info() + assert cached_tz is tz + assert info_after.hits == info_before.hits + 1 + get_app_timezone.cache_clear() + + +def test_get_app_timezone_invalid_fallback_and_warning_log(monkeypatch: pytest.MonkeyPatch) -> None: + get_app_timezone.cache_clear() + monkeypatch.setattr(settings, "TIMEZONE", "Invalid/NonExistent_Timezone") + with patch("zcore.utils.timezone.logger.warning") as mock_warn: + tz = get_app_timezone() + assert tz == datetime.UTC + mock_warn.assert_called_once() + assert mock_warn.call_args[1]["timezone"] == "Invalid/NonExistent_Timezone" + get_app_timezone.cache_clear() + + +def test_now_and_utc_now_timezone_awareness(monkeypatch: pytest.MonkeyPatch) -> None: + tehran_tz = datetime.timezone(datetime.timedelta(hours=3, minutes=30), name="Asia/Tehran") + monkeypatch.setattr("zoneinfo.ZoneInfo", lambda name: tehran_tz) + get_app_timezone.cache_clear() + monkeypatch.setattr(settings, "TIMEZONE", "Asia/Tehran") + current_now = now() + current_utc = utc_now() + + assert current_now.tzinfo is not None + assert str(current_now.tzinfo) == "Asia/Tehran" + assert current_utc.tzinfo == datetime.UTC + get_app_timezone.cache_clear() + + +def test_to_app_timezone_naive_and_aware(monkeypatch: pytest.MonkeyPatch) -> None: + tehran_tz = datetime.timezone(datetime.timedelta(hours=3, minutes=30), name="Asia/Tehran") + monkeypatch.setattr("zoneinfo.ZoneInfo", lambda name: tehran_tz) + get_app_timezone.cache_clear() + monkeypatch.setattr(settings, "TIMEZONE", "Asia/Tehran") + + assert to_app_timezone(None) is None + + naive_dt = datetime.datetime(2026, 7, 2, 10, 0, 0) + converted_naive = to_app_timezone(naive_dt) + assert converted_naive is not None + assert str(converted_naive.tzinfo) == "Asia/Tehran" + assert converted_naive.hour == 13 + assert converted_naive.minute == 30 + + aware_dt = datetime.datetime(2026, 7, 2, 10, 0, 0, tzinfo=datetime.UTC) + converted_aware = to_app_timezone(aware_dt) + assert converted_aware is not None + assert str(converted_aware.tzinfo) == "Asia/Tehran" + assert converted_aware == aware_dt + get_app_timezone.cache_clear() + + +def test_format_iso_with_app_timezone(monkeypatch: pytest.MonkeyPatch) -> None: + get_app_timezone.cache_clear() + assert format_iso_with_app_timezone(None) is None + + monkeypatch.setattr(settings, "TIMEZONE", "UTC") + utc_dt = datetime.datetime(2026, 7, 2, 10, 0, 0) + assert format_iso_with_app_timezone(utc_dt) == "2026-07-02T10:00:00+00:00" + + tehran_tz = datetime.timezone(datetime.timedelta(hours=3, minutes=30), name="Asia/Tehran") + monkeypatch.setattr("zoneinfo.ZoneInfo", lambda name: tehran_tz) + get_app_timezone.cache_clear() + monkeypatch.setattr(settings, "TIMEZONE", "Asia/Tehran") + assert format_iso_with_app_timezone(utc_dt) == "2026-07-02T13:30:00+03:30" + get_app_timezone.cache_clear() + + +def test_zdatetime_pydantic_field_serialization(monkeypatch: pytest.MonkeyPatch) -> None: + tehran_tz = datetime.timezone(datetime.timedelta(hours=3, minutes=30), name="Asia/Tehran") + monkeypatch.setattr("zoneinfo.ZoneInfo", lambda name: tehran_tz) + get_app_timezone.cache_clear() + monkeypatch.setattr(settings, "TIMEZONE", "Asia/Tehran") + + class EventSchema(BaseModel): + created_at: ZDateTime + + event = EventSchema(created_at=datetime.datetime(2026, 7, 2, 10, 0, 0)) + serialized_dict = event.model_dump(mode="json") + assert serialized_dict["created_at"] == "2026-07-02T13:30:00+03:30" + get_app_timezone.cache_clear() + + +def test_custom_json_encoder_auto_convert_timezone_toggle(monkeypatch: pytest.MonkeyPatch) -> None: + tehran_tz = datetime.timezone(datetime.timedelta(hours=3, minutes=30), name="Asia/Tehran") + monkeypatch.setattr("zoneinfo.ZoneInfo", lambda name: tehran_tz) + get_app_timezone.cache_clear() + dt = datetime.datetime(2026, 7, 2, 10, 0, 0) + + monkeypatch.setattr(settings, "TIMEZONE", "Asia/Tehran") + monkeypatch.setattr(settings, "AUTO_CONVERT_TIMEZONE", True) + assert json_loads(json_dumps(dt)) == "2026-07-02T13:30:00+03:30" + + monkeypatch.setattr(settings, "AUTO_CONVERT_TIMEZONE", False) + assert json_loads(json_dumps(dt)) == "2026-07-02T10:00:00" + get_app_timezone.cache_clear() \ No newline at end of file diff --git a/tests/test_web.py b/tests/test_web.py index e7b3f11..4fe57a1 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -1,22 +1,26 @@ import asyncio +import datetime import inspect import uuid from contextlib import asynccontextmanager, suppress from typing import Any, ClassVar -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import APIRouter, FastAPI from httpx import ASGITransport, AsyncClient from pydantic import BaseModel +from sqlalchemy import Column, Integer, Uuid from sqlalchemy.ext.asyncio import AsyncSession +from zcore.config import settings from zcore.context.context import ZContext from zcore.db.pagination import PageNumberPagination, PageNumberParams, PaginatedResult -from zcore.db.setup import db_manager +from zcore.db.setup import Base, db_manager from zcore.exceptions.base import AppException, EntityNotFound from zcore.exceptions.handlers import app_exception_handler from zcore.kernel.di import _current_scope_id, container +from zcore.utils.timezone import get_app_timezone from zcore.web.api_router import ZCoreRequest from zcore.web.base_router import BaseRouter, RouteKey from zcore.web.middleware import RequestLogMiddleware, ScopedDependencyMiddleware @@ -37,29 +41,35 @@ def actions(cls) -> Any: mock_actions.DELETE = "dummy:delete" return mock_actions + class DummyCreate(BaseModel): name: str + class DummyUpdate(BaseModel): name: str + class DummyOut(Zchema): __model__ = "dummy" id: str name: str password: str = "" + class NestedProfile(Zchema): __model__ = "profile" phone: str city: str + class DummyOutWithNested(Zchema): __model__ = "dummy" id: str name: str profile: NestedProfile + class TargetService: def __init__(self, payload: dict[str, Any]) -> None: self.payload = payload @@ -70,6 +80,7 @@ async def get(self, id: uuid.UUID) -> DummyOut: async def get_list(self, pagination: Any = None) -> list[Any]: return [] + class MockFullService: def __init__(self) -> None: pass @@ -96,7 +107,8 @@ async def update(self, id: uuid.UUID, schema: DummyUpdate, partial: bool = False async def delete(self, id: uuid.UUID) -> None: pass -def clean_endpoint_signature(endpoint): + +def clean_endpoint_signature(endpoint: Any) -> Any: sig = inspect.signature(endpoint) parameters = list(sig.parameters.values()) new_params = [p for p in parameters if p.kind != inspect.Parameter.VAR_KEYWORD] @@ -114,12 +126,13 @@ def clean_endpoint_signature(endpoint): param_line = ", ".join(param_strs) call_line = ", ".join(call_strs) func_code = f"async def clean_endpoint({param_line}):\n return await _orig_endpoint({call_line})" - local_dict = {} + local_dict: dict[str, Any] = {} exec(func_code, globals_dict, local_dict) clean_func = local_dict["clean_endpoint"] clean_func.__annotations__ = endpoint.__annotations__ return clean_func + @pytest.mark.parametrize( "router_attrs, expected_error_msg", [ @@ -159,6 +172,7 @@ def test_router_auto_scaffolding_validation_errors(router_attrs: dict[str, Any], router_cls() assert expected_error_msg in str(exc_info.value) + @pytest.mark.anyio @pytest.mark.parametrize( "restricted_fields, payload_in, expected_payload_out, expected_vary", @@ -185,14 +199,17 @@ async def test_router_schema_projection_pruning( expected_vary: list[str] ) -> None: original_add_api_route = APIRouter.add_api_route - def patched_add_api_route(self, path, endpoint, *args, **kwargs): + + def patched_add_api_route(self: Any, path: Any, endpoint: Any, *args: Any, **kwargs: Any) -> Any: clean_endpoint = clean_endpoint_signature(endpoint) return original_add_api_route(self, path, clean_endpoint, *args, **kwargs) + monkeypatch.setattr(APIRouter, "add_api_route", patched_add_api_route) monkeypatch.setattr(ZContext, "restricted_fields", property(lambda self: frozenset(restricted_fields))) app = FastAPI() mock_service = TargetService(payload_in) container.register_singleton(TargetService, mock_service) + class TargetRouter(BaseRouter[DummyCreate, DummyUpdate]): model = DummyModel create_schema = DummyCreate @@ -201,8 +218,10 @@ class TargetRouter(BaseRouter[DummyCreate, DummyUpdate]): service = TargetService prefix = "/items" expose_schemas = True + def get_route_dependencies(self, route_key: RouteKey, action: str) -> list[Any]: return [] + router_inst = TargetRouter() app.include_router(router_inst.router) transport = ASGITransport(app=app) @@ -220,6 +239,7 @@ def get_route_dependencies(self, route_key: RouteKey, action: str) -> list[Any]: schema_data = schema_resp.json() assert "password" not in schema_data["data"]["properties"] + @pytest.mark.anyio @pytest.mark.parametrize( "custom_request_id, expect_valid_uuid", @@ -231,9 +251,11 @@ def get_route_dependencies(self, route_key: RouteKey, action: str) -> list[Any]: async def test_request_id_middleware(custom_request_id: str | None, expect_valid_uuid: bool) -> None: app = FastAPI() app.add_middleware(RequestLogMiddleware) + @app.get("/health") def health_check() -> dict[str, str]: return {"status": "ok"} + transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: headers = {} @@ -248,15 +270,19 @@ def health_check() -> dict[str, str]: else: assert response_id == custom_request_id + @pytest.mark.anyio async def test_router_full_crud_endpoints(monkeypatch: pytest.MonkeyPatch) -> None: original_add_api_route = APIRouter.add_api_route - def patched_add_api_route(self, path, endpoint, *args, **kwargs): + + def patched_add_api_route(self: Any, path: Any, endpoint: Any, *args: Any, **kwargs: Any) -> Any: return original_add_api_route(self, path, clean_endpoint_signature(endpoint), *args, **kwargs) + monkeypatch.setattr(APIRouter, "add_api_route", patched_add_api_route) app = FastAPI() service_inst = MockFullService() container.register_singleton(MockFullService, service_inst) + class FullCrudRouter(BaseRouter[DummyCreate, DummyUpdate]): model = DummyModel create_schema = DummyCreate @@ -265,8 +291,10 @@ class FullCrudRouter(BaseRouter[DummyCreate, DummyUpdate]): service = MockFullService prefix = "/crud" pagination_class = PageNumberPagination + def get_route_dependencies(self, route_key: RouteKey, action: str) -> list[Any]: return [] + r = FullCrudRouter() app.include_router(r.router) transport = ASGITransport(app=app) @@ -291,13 +319,16 @@ def get_route_dependencies(self, route_key: RouteKey, action: str) -> list[Any]: assert res.status_code == 200 assert res.json()["message"] == "Deleted successfully" + @pytest.mark.anyio async def test_router_exception_translation_handler() -> None: app = FastAPI() app.add_exception_handler(AppException, app_exception_handler) + @app.get("/error") - async def raise_error(): + async def raise_error() -> None: raise EntityNotFound(message="Missing item", payload={"key": "val"}) + transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: res = await client.get("/error") @@ -307,6 +338,7 @@ async def raise_error(): assert body["message"] == "Missing item" assert body["meta"]["error_type"] == "EntityNotFound" + @pytest.mark.anyio async def test_zchema_recursive_nested_pruning(monkeypatch: pytest.MonkeyPatch) -> None: nested = NestedProfile(phone="12345", city="Tehran") @@ -316,6 +348,7 @@ async def test_zchema_recursive_nested_pruning(monkeypatch: pytest.MonkeyPatch) assert "phone" not in serialized["profile"] assert serialized["profile"]["city"] == "Tehran" + @pytest.mark.anyio async def test_zchema_wildcard_pruning(monkeypatch: pytest.MonkeyPatch) -> None: nested = NestedProfile(phone="12345", city="Tehran") @@ -324,10 +357,12 @@ async def test_zchema_wildcard_pruning(monkeypatch: pytest.MonkeyPatch) -> None: serialized = model.model_dump(mode="json") assert serialized == {} + @pytest.mark.anyio async def test_method_schema_exposure(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(ZContext, "restricted_fields", property(lambda self: frozenset({"dummy.password"}))) app = FastAPI() + class ExposureRouter(BaseRouter[DummyCreate, DummyUpdate]): model = DummyModel create_schema = DummyCreate @@ -336,8 +371,10 @@ class ExposureRouter(BaseRouter[DummyCreate, DummyUpdate]): service = MockFullService prefix = "/exposure" expose_schemas = True + def get_route_dependencies(self, route_key: RouteKey, action: str) -> list[Any]: return [] + r = ExposureRouter() app.include_router(r.router) transport = ASGITransport(app=app) @@ -349,6 +386,7 @@ def get_route_dependencies(self, route_key: RouteKey, action: str) -> list[Any]: assert res_get.status_code == 200 assert "password" not in res_get.json()["data"]["properties"] + @pytest.mark.anyio @pytest.mark.parametrize( "bad_id", @@ -362,9 +400,11 @@ def get_route_dependencies(self, route_key: RouteKey, action: str) -> list[Any]: async def test_request_id_validation_pattern(bad_id: str) -> None: app = FastAPI() app.add_middleware(RequestLogMiddleware) + @app.get("/") - def root(): + def root() -> dict[str, Any]: return {} + transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: res = await client.get("/", headers={"x-request-id": bad_id}) @@ -372,20 +412,25 @@ def root(): assert res_id != bad_id assert uuid.UUID(res_id) + @pytest.mark.anyio async def test_scoped_dependency_middleware_lifecycle(monkeypatch: pytest.MonkeyPatch) -> None: app = FastAPI() app.add_middleware(ScopedDependencyMiddleware) mock_session = AsyncMock(spec=AsyncSession) + @asynccontextmanager - async def mock_sess_manager(): + async def mock_sess_manager() -> Any: yield mock_session + monkeypatch.setattr(db_manager, "session", mock_sess_manager) + @app.get("/scoped") async def check_scope() -> dict[str, Any]: scope_id = _current_scope_id.get() registered_session = container.resolve(AsyncSession) return {"scope_id": scope_id, "session_is_mock": registered_session is mock_session} + transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: res = await client.get("/scoped") @@ -393,24 +438,30 @@ async def check_scope() -> dict[str, Any]: data = res.json() assert data["scope_id"] is not None assert data["session_is_mock"] is True + assert _current_scope_id.get() is None with pytest.raises((TypeError, Exception)): container.resolve(AsyncSession) + @pytest.mark.anyio async def test_zcore_request_body_caching() -> None: scope = {"type": "http", "method": "POST", "path": "/"} + async def mock_receive() -> dict[str, Any]: return {"type": "http.request", "body": b"test_payload", "more_body": False} + req = ZCoreRequest(scope, mock_receive) body1 = await req.body() body2 = await req.body() assert body1 == b"test_payload" assert body2 == b"test_payload" + def test_route_sorting_shadowing() -> None: class DummyService: pass + class SortRouter(BaseRouter[DummyCreate, DummyUpdate]): model = DummyModel create_schema = DummyCreate @@ -425,6 +476,7 @@ class SortRouter(BaseRouter[DummyCreate, DummyUpdate]): RouteKey.PATCH, RouteKey.DELETE, } + router_inst = SortRouter() paths = [r.path for r in router_inst.router.routes] assert "/test/search" in paths @@ -433,15 +485,18 @@ class SortRouter(BaseRouter[DummyCreate, DummyUpdate]): idx_uuid = paths.index("/test/{id:uuid}") assert idx_search < idx_uuid + @pytest.mark.anyio async def test_stream_manager_pubsub_lifecycle(monkeypatch: pytest.MonkeyPatch) -> None: redis_mock = AsyncMock() pubsub_mock = AsyncMock() redis_mock.pubsub = MagicMock(return_value=pubsub_mock) - async def mock_listen(): + + async def mock_listen() -> Any: while True: await asyncio.sleep(3600) yield {"type": "pmessage", "channel": "stream:user:123", "data": "{}"} + pubsub_mock.listen = mock_listen init_stream_redis(redis_mock) sm = StreamManager() @@ -461,4 +516,152 @@ async def mock_listen(): assert u_id not in sm.users_queues await sm.unsubscribe(u_id, queue) assert sm._pubsub_task is None - init_stream_redis(None) \ No newline at end of file + init_stream_redis(None) + + +def test_base_router_pk_type_auto_detection_int_and_uuid() -> None: + class IntModel(Base): + __tablename__ = f"int_model_{uuid.uuid4().hex[:6]}" + id = Column(Integer, primary_key=True) + + class UuidModel(Base): + __tablename__ = f"uuid_model_{uuid.uuid4().hex[:6]}" + id = Column(Uuid, primary_key=True) + + class DummyService: + pass + + class IntRouter(BaseRouter[DummyCreate, DummyUpdate]): + model = IntModel + create_schema = DummyCreate + update_schema = DummyUpdate + schema_out = DummyOut + service = DummyService + prefix = "/int-items" + + class UuidRouter(BaseRouter[DummyCreate, DummyUpdate]): + model = UuidModel + create_schema = DummyCreate + update_schema = DummyUpdate + schema_out = DummyOut + service = DummyService + prefix = "/uuid-items" + + int_r = IntRouter() + int_paths = [r.path for r in int_r.router.routes] + assert "/int-items/{id:int}" in int_paths + + uuid_r = UuidRouter() + uuid_paths = [r.path for r in uuid_r.router.routes] + assert "/uuid-items/{id:uuid}" in uuid_paths + + +def test_base_router_explicit_pk_type_override() -> None: + class DummyService: + pass + + class CustomPkRouter(BaseRouter[DummyCreate, DummyUpdate]): + model = DummyModel + pk_type = int + create_schema = DummyCreate + update_schema = DummyUpdate + schema_out = DummyOut + service = DummyService + prefix = "/custom-pk" + + r = CustomPkRouter() + paths = [route.path for route in r.router.routes] + assert "/custom-pk/{id:int}" in paths + + +def test_base_router_weighted_route_specificity_sorting() -> None: + class DummyService: + pass + + class SpecificityRouter(BaseRouter[DummyCreate, DummyUpdate]): + model = DummyModel + create_schema = DummyCreate + update_schema = DummyUpdate + schema_out = DummyOut + service = DummyService + prefix = "/api/items" + + router_inst = SpecificityRouter() + paths = [r.path for r in router_inst.router.routes] + assert "/api/items/search" in paths + assert "/api/items/{id:uuid}" in paths + + idx_search = paths.index("/api/items/search") + idx_dynamic = paths.index("/api/items/{id:uuid}") + assert idx_search < idx_dynamic + + +def test_zchema_timezone_serialization_recursive(monkeypatch: pytest.MonkeyPatch) -> None: + from zcore.utils.timezone import ZDateTime + + tehran_tz = datetime.timezone(datetime.timedelta(hours=3, minutes=30), name="Asia/Tehran") + monkeypatch.setattr("zoneinfo.ZoneInfo", lambda name: tehran_tz) + get_app_timezone.cache_clear() + monkeypatch.setattr(settings, "TIMEZONE", "Asia/Tehran") + monkeypatch.setattr(settings, "AUTO_CONVERT_TIMEZONE", True) + + class ItemDetail(Zchema): + __model__ = "item" + title: str + timestamp: ZDateTime + + class OrderSummary(Zchema): + __model__ = "order" + order_id: str + created_at: ZDateTime + items: list[ItemDetail] + + dt = datetime.datetime(2026, 7, 2, 10, 0, 0) + order = OrderSummary( + order_id="ORD-100", + created_at=dt, + items=[ItemDetail(title="Widget", timestamp=dt)], + ) + + serialized = order.model_dump(mode="json") + assert serialized["created_at"] == "2026-07-02T13:30:00+03:30" + assert serialized["items"][0]["timestamp"] == "2026-07-02T13:30:00+03:30" + get_app_timezone.cache_clear() + + +def test_zchema_input_validation_preserves_raw_types() -> None: + class InputPayload(Zchema): + __model__ = "input_test" + name: str + event_time: datetime.datetime + + dt = datetime.datetime(2026, 7, 2, 10, 0, 0) + data = {"name": "test_input", "event_time": dt} + + validated = InputPayload.model_validate(data) + assert isinstance(validated.event_time, datetime.datetime) + assert validated.event_time == dt + + +@pytest.mark.anyio +async def test_request_log_middleware_captures_status_and_client_ip() -> None: + app = FastAPI() + app.add_middleware(RequestLogMiddleware) + + @app.get("/status-test") + def get_status() -> dict[str, str]: + return {"status": "ok"} + + with patch("zcore.web.middleware.log.info") as mock_log_info: + transport = ASGITransport(app=app, client=("192.168.1.50", 54321)) + async with AsyncClient(transport=transport, base_url="http://test") as client: + res = await client.get("/status-test") + assert res.status_code == 200 + + mock_log_info.assert_called_once() + log_kwargs = mock_log_info.call_args[1] + assert log_kwargs["status_code"] == 200 + assert log_kwargs["client_ip"] == "192.168.1.50" + assert log_kwargs["path"] == "/status-test" + assert log_kwargs["method"] == "GET" + assert "duration_ms" in log_kwargs \ No newline at end of file