diff --git a/README.md b/README.md
index 0f26614..9df5557 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@
-
+
@@ -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"", "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