From b3a648b27da8503ecf883a7fd77b256710c69b36 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 12:59:03 +0330 Subject: [PATCH 01/82] feat(config): add DatabaseSettings nested configuration model - Introduced DatabaseSettings BaseModel for structured database connection config - Added fields: url, pool_size, max_overflow, pool_recycle, pool_pre_ping, echo, connect_args, execution_options, extra_engine_kwargs --- src/zcore/config.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/zcore/config.py b/src/zcore/config.py index 04da4e7..802f436 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 BaseModel, Field from pydantic_settings import BaseSettings, SettingsConfigDict from zcore.kernel.di import container @@ -17,6 +18,20 @@ 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 Settings(BaseSettings): """Core settings and environment variables configuration for the ZCore framework. From 0bd1049fe2542ba424382a17a53842f2582e5abe Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 12:59:57 +0330 Subject: [PATCH 02/82] feat(config): add backward-compatible DATABASE nested settings with sync validator - Added DATABASE field with default DatabaseSettings instance - Added model_validator to synchronize legacy DATABASE_URL, POOL_SIZE, MAX_OVERFLOW with nested DATABASE model - Ensures both flat and nested config styles remain compatible --- src/zcore/config.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/zcore/config.py b/src/zcore/config.py index 802f436..f589da0 100644 --- a/src/zcore/config.py +++ b/src/zcore/config.py @@ -10,7 +10,7 @@ import os from typing import Any, TypeVar, cast -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from zcore.kernel.di import container @@ -40,6 +40,7 @@ class Settings(BaseSettings): the database engine, authentication parameters, file storage paths, 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. @@ -58,6 +59,7 @@ 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 @@ -73,6 +75,18 @@ class Settings(BaseSettings): REDIS_URL: str | None = None DEBUG: bool = True + @model_validator(mode="after") + def _sync_database_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 + if self.MAX_OVERFLOW != 10 and self.DATABASE.max_overflow == 10: + self.DATABASE.max_overflow = self.MAX_OVERFLOW + return self + def initialize_settings(settings_inst: Settings) -> None: """Register the settings instance in the IoC dependency injection container. @@ -137,4 +151,4 @@ def __getattr__(self, name: str) -> Any: return getattr(get_settings(), name) -settings = SettingsProxy() +settings = SettingsProxy() \ No newline at end of file From 01edfb02b938a963d26c983734187e3e0a44522f Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 13:04:26 +0330 Subject: [PATCH 03/82] refactor(db): enhance DatabaseManager with structured config support - Added support for DatabaseSettings config object via init_app config parameter - Added connect_args and execution_options to engine configuration - Added pool_pre_ping as configurable parameter - Implemented config merging with proper fallback precedence - Added validation to ensure database URL is provided - Improved error handling with ValueError for missing URL --- src/zcore/db/setup.py | 93 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 76 insertions(+), 17 deletions(-) diff --git a/src/zcore/db/setup.py b/src/zcore/db/setup.py index 52a2892..3feecfc 100644 --- a/src/zcore/db/setup.py +++ b/src/zcore/db/setup.py @@ -127,7 +127,6 @@ def after_cursor_execute( compact_statement = " ".join(statement.split()) - # Bypass low-level database metadata or catalog lookups to avoid noise ignored_keywords = [ "pg_catalog", "schema", @@ -146,47 +145,107 @@ 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, and execution_options. 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, + "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 +299,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 From 77d0540814bc0ee02fe401b003cff9984123dd69 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 13:07:34 +0330 Subject: [PATCH 04/82] feat(db): add custom JSON serializer/deserializer to database engine - Added json_dumps and json_loads from zcore.utils.helpers to engine kwargs - Enables native JSON handling for UUID, Decimal, datetime types in JSON columns --- src/zcore/db/setup.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/zcore/db/setup.py b/src/zcore/db/setup.py index 3feecfc..9cf6f80 100644 --- a/src/zcore/db/setup.py +++ b/src/zcore/db/setup.py @@ -24,6 +24,7 @@ from sqlalchemy.orm import DeclarativeBase from zcore.kernel.di import container +from zcore.utils.helpers import json_dumps, json_loads @dataclass(frozen=True) @@ -160,7 +161,7 @@ def init_app( Configures parameters for SQLite and server-based relational engines (such as PostgreSQL or MySQL), supporting structured settings, custom - connect_args, and execution_options. + connect_args, execution_options, and JSON serializers. Args: db_url: The primary database connection URL. Defaults to None. @@ -221,6 +222,8 @@ def 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, } From 6adfa6b345213b5a8ffbbf7634a687243eebe4bc Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 13:18:18 +0330 Subject: [PATCH 05/82] feat(repository): add dialect-aware fallback for create_multi without RETURNING - Added dialect detection for insert_returning support - Uses RETURNING when available (PostgreSQL, modern SQLite) - Falls back to add_all + flush for databases without RETURNING (MySQL, older SQLite) - Preserves refresh flag behavior in both code paths --- src/zcore/db/repository.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/zcore/db/repository.py b/src/zcore/db/repository.py index 8e160d5..473be0f 100644 --- a/src/zcore/db/repository.py +++ b/src/zcore/db/repository.py @@ -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,10 +262,26 @@ 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 From 24d80bf0169865e0bd4e52ca6c63ac1114987ce8 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 13:19:13 +0330 Subject: [PATCH 06/82] feat(repository): add dialect-aware fallback for delete_multi without RETURNING - Added dialect detection for delete_returning support - Uses RETURNING when available (PostgreSQL, modern SQLite) - Falls back to fetch-then-delete for databases without RETURNING (MySQL, older SQLite) --- src/zcore/db/repository.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/zcore/db/repository.py b/src/zcore/db/repository.py index 473be0f..0a423cd 100644 --- a/src/zcore/db/repository.py +++ b/src/zcore/db/repository.py @@ -362,7 +362,7 @@ async def delete(self, id: Any) -> ModelType | None: 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. @@ -373,10 +373,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]): From 771fbdb791d5625649e04598bf7d2dc02c15abb9 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 13:20:50 +0330 Subject: [PATCH 07/82] refactor(repository): clean up docstring and fix trailing newline - Removed stale StaleDataError mention from update_multi docstring - Added missing parameter descriptions for partial and refresh - Added trailing newline at EOF --- src/zcore/db/repository.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/zcore/db/repository.py b/src/zcore/db/repository.py index 0a423cd..b95ec6c 100644 --- a/src/zcore/db/repository.py +++ b/src/zcore/db/repository.py @@ -319,12 +319,11 @@ async def update_multi( Args: data: A mapping of 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 [] @@ -441,4 +440,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 From c950cdda4c0225a12dcd1d4008f11bb86eed025a Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 13:37:22 +0330 Subject: [PATCH 08/82] feat(config): add timezone configuration options - Added TIMEZONE setting with default 'UTC' - Added AUTO_CONVERT_TIMEZONE flag defaulting to True - Updated docstring to reflect new timezone policy attributes --- src/zcore/config.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/zcore/config.py b/src/zcore/config.py index f589da0..d4f9f5e 100644 --- a/src/zcore/config.py +++ b/src/zcore/config.py @@ -37,7 +37,8 @@ class Settings(BaseSettings): 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, authentication parameters, file storage paths, timezone policies, + and other core services. Attributes: DATABASE: Structured configuration model for database engine settings. @@ -45,6 +46,8 @@ class Settings(BaseSettings): 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. + 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. @@ -65,6 +68,9 @@ class Settings(BaseSettings): POOL_SIZE: int = 5 DATABASE_TEST_URL: str = "sqlite+aiosqlite:///zcore_test.db" + 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" From 57687756bf629b8960d7a35e73c5c78827fbe04f Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 13:38:20 +0330 Subject: [PATCH 09/82] feat(utils): add timezone management module with ZDateTime type - Added get_app_timezone() with lru_cache for ZoneInfo resolution - Added now() for current time in app timezone - Added utc_now() for current time in UTC - Added to_app_timezone() for datetime conversion with naive fallback to UTC - Added format_iso_with_app_timezone() for ISO formatting - Added ZDateTime type alias with Pydantic PlainSerializer for automatic timezone conversion on JSON serialization --- src/zcore/utils/timezone.py | 103 ++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 src/zcore/utils/timezone.py diff --git a/src/zcore/utils/timezone.py b/src/zcore/utils/timezone.py new file mode 100644 index 0000000..7885320 --- /dev/null +++ b/src/zcore/utils/timezone.py @@ -0,0 +1,103 @@ +"""ZCore Dynamic Timezone and DateTime Management Module. + +This module provides timezone-aware datetime utilities leveraging Python's +standard `zoneinfo.ZoneInfo` (IANA database). It coordinates application-level +timezone conversions, timezone-aware current time generation, and automated +ISO 8601 formatting with offsets. +""" + +import functools +import zoneinfo +from datetime import datetime, timezone +from typing import Annotated + +from pydantic import PlainSerializer + +from zcore.config import settings + + +@functools.lru_cache(maxsize=16) +def get_app_timezone() -> zoneinfo.ZoneInfo: + """Retrieve and cache the active application ZoneInfo object based on settings. + + Returns: + A ZoneInfo instance representing the application's configured timezone, + falling back to UTC if the configured identifier is invalid. + """ + tz_name = getattr(settings, "TIMEZONE", "UTC") + try: + return zoneinfo.ZoneInfo(tz_name) + except Exception: + return zoneinfo.ZoneInfo("UTC") + + +def now() -> datetime: + """Generate current timezone-aware datetime in the application's configured timezone. + + Returns: + Current datetime instance bound to the application timezone. + """ + return datetime.now(get_app_timezone()) + + +def utc_now() -> datetime: + """Generate current timezone-aware datetime in UTC. + + Returns: + Current datetime instance bound to UTC. + """ + return datetime.now(timezone.utc) + + +def to_app_timezone(dt: datetime | None) -> datetime | None: + """Convert any datetime object to the application's configured timezone. + + If the input datetime is naive (lacks tzinfo), it is assumed to be stored in UTC + before undergoing offset transformation. + + Args: + dt: The source datetime object to convert, or None. + + Returns: + The converted datetime in the application's timezone, or None if input was None. + """ + if dt is None: + return None + + target_tz = get_app_timezone() + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + + return dt.astimezone(target_tz) + + +def format_iso_with_app_timezone(dt: datetime | None) -> str | None: + """Convert a datetime to application timezone and format as an ISO 8601 string. + + Args: + dt: The source datetime object to convert and format. + + Returns: + ISO 8601 formatted string with offset, or None if input is None. + """ + if dt is None: + return None + converted = to_app_timezone(dt) + return converted.isoformat() if converted is not None else None + + +ZDateTime = Annotated[ + datetime, + PlainSerializer( + format_iso_with_app_timezone, return_type=str, when_used="json" + ), +] + +__all__ = [ + "ZDateTime", + "format_iso_with_app_timezone", + "get_app_timezone", + "now", + "to_app_timezone", + "utc_now", +] \ No newline at end of file From e07281035f5e964578594ef6b0c0a48992b0c261 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 13:39:07 +0330 Subject: [PATCH 10/82] feat(utils): add timezone-aware datetime serialization to CustomJSONEncoder - Added AUTO_CONVERT_TIMEZONE setting check before serializing datetime objects - Uses format_iso_with_app_timezone to apply app timezone when enabled - Falls back to standard isoformat when timezone conversion is disabled - Updated docstring to reflect timezone-aware serialization behavior --- src/zcore/utils/helpers.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/zcore/utils/helpers.py b/src/zcore/utils/helpers.py index 432de8c..e7a1b02 100644 --- a/src/zcore/utils/helpers.py +++ b/src/zcore/utils/helpers.py @@ -1,8 +1,8 @@ """Common Utility Helper Functions. This module provides common utility helper functions, including a custom JSON encoder -that safely processes dates, decimals, and UUIDs, corresponding serialization/deserialization -wrappers, and a text transformation utility to generate URL-safe slugs. +that safely processes dates, decimals, UUIDs, and timezone-aware datetimes, corresponding +serialization/deserialization wrappers, and a text transformation utility to generate URL-safe slugs. """ import json @@ -14,7 +14,9 @@ from pydantic import HttpUrl, PlainSerializer -# Serializes HttpUrl objects into clean strings dynamically for Pydantic exports +from zcore.config import settings +from zcore.utils.timezone import format_iso_with_app_timezone + SafeUrl = Annotated[HttpUrl, PlainSerializer(lambda v: str(v), return_type=str)] @@ -38,11 +40,11 @@ def slugify(text: str) -> str: class CustomJSONEncoder(json.JSONEncoder): - """JSON encoder capable of serializing advanced Python data types. + """JSON encoder capable of serializing advanced Python data types with timezone awareness. Extends the standard library JSONEncoder to serialize instances of UUID, - datetime/date/time, and Decimal, falling back to a string representation - for other unrecognized objects to prevent serialization failures. + datetime/date/time, and Decimal, applying application-level timezone conversions + on datetime objects according to framework settings. """ def default(self, obj: Any) -> Any: @@ -56,7 +58,11 @@ def default(self, obj: Any) -> Any: """ if isinstance(obj, uuid.UUID): return str(obj) - if isinstance(obj, (datetime, date, time)): + if isinstance(obj, datetime): + if getattr(settings, "AUTO_CONVERT_TIMEZONE", True): + return format_iso_with_app_timezone(obj) + return obj.isoformat() + if isinstance(obj, (date, time)): return obj.isoformat() if isinstance(obj, Decimal): return str(obj) @@ -89,4 +95,4 @@ def json_loads(s: str | bytes, **kwargs: Any) -> Any: Returns: The parsed Python primitive values or collection structures. """ - return json.loads(s, **kwargs) + return json.loads(s, **kwargs) \ No newline at end of file From a06b14b60321256b1f32e663eac7761df4e6d36a Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 13:52:36 +0330 Subject: [PATCH 11/82] docs(db): add docstring for _register_query_logger sync_engine parameter --- src/zcore/db/setup.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/zcore/db/setup.py b/src/zcore/db/setup.py index 9cf6f80..e0fc53f 100644 --- a/src/zcore/db/setup.py +++ b/src/zcore/db/setup.py @@ -107,7 +107,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}") From f54f0d5163f39d64e36f4256638ad0661064becd Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 13:55:24 +0330 Subject: [PATCH 12/82] feat(zchema): integrate timezone-aware datetime serialization into Zchema - Added auto_convert_tz flag to _prune_data for recursive datetime formatting - Added datetime conversion using format_iso_with_app_timezone during serialization - Recursively processes nested dicts and lists for datetime formatting - Checks AUTO_CONVERT_TIMEZONE setting in secure_serializer - Preserves input validation pruning without timezone conversion --- src/zcore/web/projection.py | 70 ++++++++++++++++++++++++++----------- 1 file changed, 50 insertions(+), 20 deletions(-) diff --git a/src/zcore/web/projection.py b/src/zcore/web/projection.py index dfa27bc..35ce10e 100644 --- a/src/zcore/web/projection.py +++ b/src/zcore/web/projection.py @@ -1,15 +1,18 @@ """Unified Schema and Response Pruning Hub. This module provides the core `Zchema` base class, which integrates Pydantic V2 -dynamic JSON schema generation, input validation, and response serialization -filtering based on domain-isolated context restriction definitions. +dynamic JSON schema generation, input validation, automatic timezone conversions, +and response serialization filtering based on domain-isolated context restriction definitions. """ +from datetime import datetime from typing import Any, ClassVar from pydantic import BaseModel, model_serializer, model_validator +from zcore.config import settings from zcore.context.context import ctx +from zcore.utils.timezone import format_iso_with_app_timezone class Zchema(BaseModel): @@ -17,7 +20,7 @@ class Zchema(BaseModel): Subclasses specify their unique database domain mapping via the `__model__` class attribute. This enables contextual, recursive pruning across schema generation, - input validation, and response serialization. + input validation, automated timezone formatting, and response serialization. """ __model__: ClassVar[str | None] = None @@ -53,16 +56,20 @@ def _get_relative_restricted_paths(cls) -> set[str]: return relative_paths @classmethod - def _prune_data(cls, data: Any, relative_paths: set[str]) -> Any: - """Recursively strip restricted attributes from dict representations.""" - if not relative_paths or not isinstance(data, dict): + def _prune_data( + cls, + data: Any, + relative_paths: set[str], + auto_convert_tz: bool = False, + ) -> Any: + """Recursively strip restricted attributes and format datetimes with app timezone.""" + if not isinstance(data, dict): return data if "*" in relative_paths: data.clear() return data - # Group remaining nested relative paths by top-level keys nested_restrictions: dict[str, set[str]] = {} for path in relative_paths: parts = path.split(".", 1) @@ -74,18 +81,38 @@ def _prune_data(cls, data: Any, relative_paths: set[str]) -> Any: nested_restrictions[key] = set() nested_restrictions[key].add(remaining) - # Process nested levels recursively - for key, remaining_paths in nested_restrictions.items(): - if key in data: - if isinstance(data[key], dict): - data[key] = cls._prune_data(data[key], remaining_paths) - elif isinstance(data[key], list): + for key in list(data.keys()): + val = data[key] + if auto_convert_tz and isinstance(val, datetime): + data[key] = format_iso_with_app_timezone(val) + elif key in nested_restrictions: + rem_paths = nested_restrictions[key] + if isinstance(val, dict): + data[key] = cls._prune_data( + val, rem_paths, auto_convert_tz=auto_convert_tz + ) + elif isinstance(val, list): data[key] = [ - cls._prune_data(item, remaining_paths) + cls._prune_data( + item, rem_paths, auto_convert_tz=auto_convert_tz + ) if isinstance(item, dict) else item - for item in data[key] + for item in val ] + elif isinstance(val, dict): + data[key] = cls._prune_data( + val, set(), auto_convert_tz=auto_convert_tz + ) + elif isinstance(val, list): + data[key] = [ + cls._prune_data( + item, set(), auto_convert_tz=auto_convert_tz + ) + if isinstance(item, dict) + else item + for item in val + ] return data @classmethod @@ -140,18 +167,21 @@ def filter_restricted_inputs(cls, data: Any) -> Any: if isinstance(data, dict): data_copy = dict(data) - return cls._prune_data(data_copy, relative_paths) + return cls._prune_data(data_copy, relative_paths, auto_convert_tz=False) return data @model_serializer(mode="wrap") def secure_serializer(self, handler: Any) -> Any: - """Securely intercept serialization to prune restricted attributes from response.""" + """Securely intercept serialization to prune restricted attributes and format timezones.""" serialized = handler(self) relative_paths = self._get_relative_restricted_paths() - if not relative_paths or serialized is None: + auto_convert_tz = getattr(settings, "AUTO_CONVERT_TIMEZONE", True) + if serialized is None: return serialized if isinstance(serialized, dict): serialized_copy = dict(serialized) - return self._prune_data(serialized_copy, relative_paths) - return serialized + return self._prune_data( + serialized_copy, relative_paths, auto_convert_tz=auto_convert_tz + ) + return serialized \ No newline at end of file From 23902c1df0c7863c7986a6e61b9883839792e206 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 13:56:09 +0330 Subject: [PATCH 13/82] refactor(soft_delete): use timezone-aware now() for soft delete timestamps - Replaced datetime.now(UTC) with zcore.utils.timezone.now() - Added module docstring and class docstring - Added timezone=True to deleted_at column for timezone-aware storage --- src/zcore/db/soft_delete.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 From 802db622cffa902abd1cce77d44b126afc7ba7e5 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 14:01:27 +0330 Subject: [PATCH 14/82] feat(repository): support model instance or primary key in update method - Changed update() parameter from id to target accepting either ModelType instance or primary key - Checks if target is an instance of self.model; uses it directly if so - Falls back to primary key lookup if target is not a model instance - Avoids unnecessary database fetch when model instance is already available --- src/zcore/db/repository.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/zcore/db/repository.py b/src/zcore/db/repository.py index b95ec6c..c267439 100644 --- a/src/zcore/db/repository.py +++ b/src/zcore/db/repository.py @@ -284,12 +284,12 @@ async def create_multi( 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. @@ -298,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(): From 64bfa0fb16ef85a7befe4aa74f73955bdf54a512 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 14:02:44 +0330 Subject: [PATCH 15/82] feat(repository): support model instance keys in update_multi - Modified update_multi to accept model instances or primary keys as dictionary keys - Extracts primary key using getattr(key, self.pk_name, key) for both types - Collects target_ids for post-update fetch - Uses extracted IDs for get_by_ids return --- src/zcore/db/repository.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/zcore/db/repository.py b/src/zcore/db/repository.py index c267439..04916d5 100644 --- a/src/zcore/db/repository.py +++ b/src/zcore/db/repository.py @@ -318,11 +318,8 @@ async def update_multi( ) -> 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. @@ -332,13 +329,17 @@ async def update_multi( 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), @@ -346,7 +347,7 @@ 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. From b93b04d788698594d566fbf3eb1ddb02e2509e78 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 14:03:51 +0330 Subject: [PATCH 16/82] feat(repository): support model instance in delete method - Changed delete() parameter from id to target accepting either ModelType instance or primary key - Uses target directly if it is a model instance - Falls back to primary key lookup otherwise - Avoids unnecessary database fetch when instance is available --- src/zcore/db/repository.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/zcore/db/repository.py b/src/zcore/db/repository.py index 04916d5..bf77e98 100644 --- a/src/zcore/db/repository.py +++ b/src/zcore/db/repository.py @@ -314,7 +314,7 @@ 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. @@ -349,18 +349,22 @@ async def update_multi( 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 From 1d8e290b127b1069bde32ed2a2a3d4a23cdca741 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 14:06:20 +0330 Subject: [PATCH 17/82] refactor(service): update pre_update and pre_update_multi signatures - Changed pre_update id parameter to target accepting ModelType or primary key - Added docstring with parameter descriptions for pre_update - Updated pre_update_multi data type hint to dict[ModelType | Any, BaseModel] --- src/zcore/service/base.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/zcore/service/base.py b/src/zcore/service/base.py index 8402b66..606e091 100644 --- a/src/zcore/service/base.py +++ b/src/zcore/service/base.py @@ -176,9 +176,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 +194,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 From 34e68f818b00a115708fcf6fe81d451cf113564b Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 14:07:24 +0330 Subject: [PATCH 18/82] refactor(service): update on_update and on_update_multi signatures - Changed on_update id parameter to target accepting ModelType or primary key - Updated on_update_multi data type hint to dict[ModelType | Any, BaseModel] --- src/zcore/service/base.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/zcore/service/base.py b/src/zcore/service/base.py index 606e091..047bed5 100644 --- a/src/zcore/service/base.py +++ b/src/zcore/service/base.py @@ -241,13 +241,20 @@ 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) From b4a004404393da3aeecebaaada132eadacefeb8c Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 14:10:00 +0330 Subject: [PATCH 19/82] refactor(service): update update() and update_multi signatures - Changed update() id parameter to target accepting ModelType or primary key - Updated docstring to reflect new target parameter behavior - Updated update_multi data type hint to dict[ModelType | Any, BaseModel] - Added trailing newline at EOF --- src/zcore/service/base.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/zcore/service/base.py b/src/zcore/service/base.py index 047bed5..48255a3 100644 --- a/src/zcore/service/base.py +++ b/src/zcore/service/base.py @@ -297,14 +297,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. @@ -315,9 +317,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) @@ -325,7 +327,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) @@ -404,4 +409,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 From fb4e492af9a3ee1e345e9d336b2f328b0ec3b1e7 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 14:10:34 +0330 Subject: [PATCH 20/82] refactor(service): update delete and on_delete signatures - Changed on_delete id parameter to target accepting ModelType or primary key - Changed delete id parameter to target accepting ModelType or primary key - Added docstring with parameter description and exception documentation for delete --- src/zcore/service/base.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/zcore/service/base.py b/src/zcore/service/base.py index 48255a3..9593b37 100644 --- a/src/zcore/service/base.py +++ b/src/zcore/service/base.py @@ -259,9 +259,9 @@ async def on_update_multi( """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.""" @@ -339,10 +339,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) From c6b1c70a3c8ae98ee54a9c65019c385ae705c954 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 14:15:22 +0330 Subject: [PATCH 21/82] feat(search): improve type coercion with better error handling and validation - Added support for set type coercion alongside list and tuple - Added robust fallback for columns without python_type attribute - Added explicit int/float coercion for string values - Replaced generic exception catching with specific ValueError/TypeError - Added ValidationError with meaningful error message and column context --- src/zcore/db/search.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/zcore/db/search.py b/src/zcore/db/search.py index b273edf..3029e3d 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 @@ -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,8 +406,14 @@ 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 From a7f2f6f279e57516c8f5b734bd0206e8f9461228 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 14:16:04 +0330 Subject: [PATCH 22/82] feat(search): enhance type safety and operator handling in comparison builder - Added cast to String for text operators (ilike, startswith, endswith, contains) on non-string columns - Improved contains operator with fallback cast for unsupported column types - Added proper in operator coercion with set/list/tuple support - Added ValidationError for between operator with descriptive column context - Removed redundant in operator logic and consolidated at top --- src/zcore/db/search.py | 83 ++++++++++++++++++++++++++++-------------- 1 file changed, 56 insertions(+), 27 deletions(-) diff --git a/src/zcore/db/search.py b/src/zcore/db/search.py index 3029e3d..b220308 100644 --- a/src/zcore/db/search.py +++ b/src/zcore/db/search.py @@ -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 @@ -418,44 +418,80 @@ def _coerce_value(self, col: Any, value: Any) -> Any: 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 in ("ilike", "startswith", "endswith"): + 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 == "ilike": + 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 == "contains": - if hasattr(col.type, "python_type") and col.type.python_type is str: - escaped_value = self._escape_like_wildcards(value) + try: + is_str = ( + hasattr(col.type, "python_type") and col.type.python_type is str + ) + except (NotImplementedError, AttributeError): + is_str = False + + if is_str: + escaped_value = self._escape_like_wildcards( + str(value) if value is not None else "" + ) return col.ilike(f"%{escaped_value}%", escape="\\") - return col.contains(value) + + try: + return col.contains(value) + except Exception: + escaped_value = self._escape_like_wildcards( + str(value) if value is not None else "" + ) + return cast(col, String).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": @@ -471,13 +507,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: @@ -562,4 +591,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 From accdd7e6b594c24082532199bf75b46bc9af2421 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 14:19:30 +0330 Subject: [PATCH 23/82] refactor(router): remove **kwargs propagation and clean up imports - Removed **kwargs from all internal endpoint wrapper functions - Removed **kwargs from all endpoint handler methods (create, get, get_all, search, update, patch, delete) - Added explicit imports for CursorParams, PageNumberParams, PaginatedResult - Removed TYPE_CHECKING conditional for BasePagination - Removed inline imports from endpoint methods - Added trailing newline at EOF --- src/zcore/web/base_router.py | 78 ++++++++++++------------------------ 1 file changed, 26 insertions(+), 52 deletions(-) diff --git a/src/zcore/web/base_router.py b/src/zcore/web/base_router.py index ab44906..6fb97a2 100644 --- a/src/zcore/web/base_router.py +++ b/src/zcore/web/base_router.py @@ -7,13 +7,19 @@ import uuid from enum import StrEnum -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from typing import Any, Generic, TypeVar from fastapi import APIRouter, Depends, status from fastapi.params import Depends as DependsClass from fastapi.routing import APIRoute from pydantic import BaseModel +from zcore.db.pagination import ( + BasePagination, + CursorParams, + PageNumberParams, + PaginatedResult, +) from zcore.db.search import SearchRequest from zcore.kernel.di import Injector from zcore.security.permissions import HasScopes @@ -21,9 +27,6 @@ from zcore.web.api_router import ZCoreAPIRoute from zcore.web.response import ResponseWrapper -if TYPE_CHECKING: - from zcore.db.pagination import BasePagination - CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) @@ -69,7 +72,7 @@ class BaseRouter(Generic[CreateSchemaType, UpdateSchemaType]): prefix: str = "" tags: list[str] | None = None exclude: set[RouteKey] | None = None - pagination_class: type["BasePagination"] | None = None + pagination_class: type[BasePagination] | None = None route_class: type[APIRoute] = ZCoreAPIRoute expose_schemas: set[RouteKey] | bool = False @@ -82,7 +85,6 @@ def __init__(self) -> None: Raises: ValueError: If the required service parameter is not configured. """ - # Fail-Fast Startup Verification if not self.service: raise ValueError( f"Service class must be defined in '{self.__class__.__name__}'." @@ -186,17 +188,14 @@ def get_route_action(self, route_key: RouteKey) -> str: return action_map[route_key] def get_route_dependencies(self, route_key: RouteKey, action: str) -> list[Any]: - """Generate default route dependencies (authentication, authorization, logging, etc.). - - Subclasses can override this method to inject dynamic, runtime dependencies - with full OOP flexibility. + """Generate default route dependencies. Args: route_key: The target operational route key. action: The computed database/permission action identifier. Returns: - A list of dependencies (callable, classes or FastAPI Depends objects). + A list of dependencies. """ if not action: return [] @@ -220,10 +219,7 @@ def _get_route_dependencies(self, route_key: RouteKey) -> list[DependsClass]: return self._normalize_dependencies(dependencies) def _sort_routes(self) -> None: - """Sort routes to place parameterized dynamic paths at the end of the routing table. - - This avoids dynamic routes (e.g., /{id:uuid}) from shadowing static sibling endpoints (e.g., /search). - """ + """Sort routes to place parameterized dynamic paths at the end of the routing table.""" self.router.routes.sort(key=lambda r: "{" in r.path) def _register_routes(self) -> None: @@ -237,9 +233,8 @@ def _register_routes(self) -> None: async def _create_endpoint( data_in: c_schema, service_inst: BaseService = service_dependency, - **kwargs: Any, ) -> ResponseWrapper: - return await self.create_endpoint(data_in, service_inst, **kwargs) + return await self.create_endpoint(data_in, service_inst) self.router.add_api_route( path="/", @@ -258,9 +253,8 @@ async def _create_endpoint( async def _get_endpoint( id: uuid.UUID, service_inst: BaseService = service_dependency, - **kwargs: Any, ) -> ResponseWrapper: - return await self.get_endpoint(id, service_inst, **kwargs) + return await self.get_endpoint(id, service_inst) self.router.add_api_route( path="/{id:uuid}", @@ -281,15 +275,14 @@ async def _get_endpoint( async def _get_all_endpoint( params: params_class = Depends(), service_inst: BaseService = service_dependency, - **kwargs: Any, ) -> ResponseWrapper: - return await self.get_all_endpoint(service_inst, params, **kwargs) + return await self.get_all_endpoint(service_inst, params) else: async def _get_all_endpoint( - service_inst: BaseService = service_dependency, **kwargs: Any + service_inst: BaseService = service_dependency, ) -> ResponseWrapper: - return await self.get_all_endpoint(service_inst, **kwargs) + return await self.get_all_endpoint(service_inst) self.router.add_api_route( path="/", @@ -308,9 +301,8 @@ async def _get_all_endpoint( async def _search_endpoint( search_in: SearchRequest, service_inst: BaseService = service_dependency, - **kwargs: Any, ) -> ResponseWrapper: - return await self.search_endpoint(search_in, service_inst, **kwargs) + return await self.search_endpoint(search_in, service_inst) self.router.add_api_route( path="/search", @@ -331,9 +323,8 @@ async def _update_endpoint( id: uuid.UUID, data_in: u_schema, service_inst: BaseService = service_dependency, - **kwargs: Any, ) -> ResponseWrapper: - return await self.update_endpoint(id, data_in, service_inst, **kwargs) + return await self.update_endpoint(id, data_in, service_inst) self.router.add_api_route( path="/{id:uuid}", @@ -354,9 +345,8 @@ async def _patch_endpoint( id: uuid.UUID, data_in: u_schema, service_inst: BaseService = service_dependency, - **kwargs: Any, ) -> ResponseWrapper: - return await self.patch_endpoint(id, data_in, service_inst, **kwargs) + return await self.patch_endpoint(id, data_in, service_inst) self.router.add_api_route( path="/{id:uuid}", @@ -375,9 +365,8 @@ async def _patch_endpoint( async def _delete_endpoint( id: uuid.UUID, service_inst: BaseService = service_dependency, - **kwargs: Any, ) -> ResponseWrapper: - return await self.delete_endpoint(id, service_inst, **kwargs) + return await self.delete_endpoint(id, service_inst) self.router.add_api_route( path="/{id:uuid}", @@ -392,14 +381,13 @@ async def _delete_endpoint( self._sort_routes() async def create_endpoint( - self, data_in: CreateSchemaType, service: BaseService, **kwargs: Any + self, data_in: CreateSchemaType, service: BaseService ) -> ResponseWrapper: """Execute the POST creation transaction. Args: data_in: Validated input schema containing creation properties. service: Active business service instance. - **kwargs: Extra dynamic route arguments. Returns: The created entity wrapped in a ResponseWrapper. @@ -408,14 +396,13 @@ async def create_endpoint( return ResponseWrapper(data=data) async def get_endpoint( - self, id: uuid.UUID, service: BaseService, **kwargs: Any + self, id: uuid.UUID, service: BaseService ) -> ResponseWrapper: """Execute a single-record query lookup. Args: id: The primary key of the target entity. service: Active business service instance. - **kwargs: Extra dynamic route arguments. Returns: The resolved model record wrapped in a ResponseWrapper. @@ -424,41 +411,35 @@ async def get_endpoint( return ResponseWrapper(data=data) async def get_all_endpoint( - self, service: BaseService, pagination: Any = None, **kwargs: Any + self, service: BaseService, pagination: Any = None ) -> ResponseWrapper: """Execute batch query listings, applying optional page boundaries. Args: service: Active business service instance. pagination: Optional offset or keyset cursor parameters. Defaults to None. - **kwargs: Extra dynamic route arguments. Returns: The list of resolved model records wrapped in a ResponseWrapper. """ result = await service.get_list(pagination) - from zcore.db.pagination import PaginatedResult - if isinstance(result, PaginatedResult): return ResponseWrapper(data=result.data, meta=result.meta) return ResponseWrapper(data=result) async def search_endpoint( - self, search_in: SearchRequest, service: BaseService, **kwargs: Any + self, search_in: SearchRequest, service: BaseService ) -> ResponseWrapper: """Execute dynamic filter searches, applying mapped page limits. Args: search_in: Target filtering limits request parameters. service: Active business service instance. - **kwargs: Extra dynamic route arguments. Returns: The matching model records wrapped in a ResponseWrapper. """ pagination = None - from zcore.db.pagination import CursorParams, PageNumberParams - if self.pagination_class: if self.pagination_class.params_class == CursorParams: pagination = CursorParams(cursor=search_in.cursor, size=search_in.size) @@ -466,8 +447,6 @@ async def search_endpoint( pagination = PageNumberParams(page=search_in.page, size=search_in.size) result = await service.search(search_in, pagination) - from zcore.db.pagination import PaginatedResult - if isinstance(result, PaginatedResult): return ResponseWrapper(data=result.data, meta=result.meta) return ResponseWrapper(data=result) @@ -477,7 +456,6 @@ async def update_endpoint( id: uuid.UUID, data_in: UpdateSchemaType, service: BaseService, - **kwargs: Any, ) -> ResponseWrapper: """Execute a full-record entity update transaction. @@ -485,7 +463,6 @@ async def update_endpoint( id: Target entity identifier to update. data_in: Validated schema containing updated properties. service: Active business service instance. - **kwargs: Extra dynamic route arguments. Returns: The updated model record wrapped in a ResponseWrapper. @@ -498,7 +475,6 @@ async def patch_endpoint( id: uuid.UUID, data_in: UpdateSchemaType, service: BaseService, - **kwargs: Any, ) -> ResponseWrapper: """Execute a partial record update (PATCH) transaction. @@ -506,7 +482,6 @@ async def patch_endpoint( id: Target entity identifier to patch. data_in: Validated schema containing partial changes. service: Active business service instance. - **kwargs: Extra dynamic route arguments. Returns: The updated model record wrapped in a ResponseWrapper. @@ -515,17 +490,16 @@ async def patch_endpoint( return ResponseWrapper(data=data) async def delete_endpoint( - self, id: uuid.UUID, service: BaseService, **kwargs: Any + self, id: uuid.UUID, service: BaseService ) -> ResponseWrapper: """Execute a single-record delete transaction. Args: id: Target entity identifier to delete. service: Active business service instance. - **kwargs: Extra dynamic route arguments. Returns: A success response wrapped in a ResponseWrapper. """ await service.delete(id) - return ResponseWrapper(message="Deleted successfully") + return ResponseWrapper(message="Deleted successfully") \ No newline at end of file From 4c6d53d337c8bbc622f8667235bac093110b2909 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 14:20:30 +0330 Subject: [PATCH 24/82] refactor(pagination): clean up docstring formatting and add trailing newline - Simplified PaginatedResult meta attribute docstring - Added trailing newline at EOF --- src/zcore/db/pagination.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 From a93ced47e513f8aec2e5a69647eecda6df4e1cb7 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 14:31:36 +0330 Subject: [PATCH 25/82] feat(router): dynamic primary key type resolution for route paths - Added pk_type attribute for explicit PK type override - Added _resolve_pk_type() to auto-detect from model metadata with fallback to UUID - Added _get_pk_path() to construct dynamic URL path segments based on PK type - Supports int, UUID, and fallback generic path for other types - Replaced hardcoded {id:uuid} with dynamic pk_path in all CRUD route endpoints - Updated endpoint signatures to use generic Any for id parameter - Updated docstrings to reflect dynamic PK behavior --- src/zcore/web/base_router.py | 86 +++++++++++++++++++++++++++--------- 1 file changed, 65 insertions(+), 21 deletions(-) diff --git a/src/zcore/web/base_router.py b/src/zcore/web/base_router.py index 6fb97a2..5a1c0c0 100644 --- a/src/zcore/web/base_router.py +++ b/src/zcore/web/base_router.py @@ -2,7 +2,8 @@ This module provides the generic `BaseRouter` interface, which scaffolds standard security-aware CRUD endpoints (POST, GET, GET_ALL, SEARCH, UPDATE, PATCH, DELETE) -and integrates them with services, schemas, dependency requirements, and pagination handlers. +and integrates them with services, schemas, dependency requirements, and pagination handlers, +with clean declarative syntax and zero-configuration primary key auto-detection. """ import uuid @@ -44,10 +45,10 @@ class RouteKey(StrEnum): class BaseRouter(Generic[CreateSchemaType, UpdateSchemaType]): - """Generic web router orchestrator. + """Declarative web router orchestrator. - Automatically maps operations to matching database model dependencies and handles - dependency injections and schema checks. + Automatically maps CRUD operations to matching database model dependencies and handles + dependency injections, schema validations, and dynamic primary key route resolution. Attributes: model: The database declarative model class. @@ -55,6 +56,7 @@ class BaseRouter(Generic[CreateSchemaType, UpdateSchemaType]): update_schema: Schema class for validating entity updates. schema_out: Schema class representing responses. service: Business service callable class. + pk_type: Optional explicit primary key type. If None, auto-resolved from model metadata. prefix: Path prefix representing the route. tags: Endpoint group classification tags. exclude: Explicit endpoints to bypass during route scaffolding. @@ -64,10 +66,11 @@ class BaseRouter(Generic[CreateSchemaType, UpdateSchemaType]): """ model: type[Any] - create_schema: type[CreateSchemaType] | None = None - update_schema: type[UpdateSchemaType] | None = None + create_schema: type[CreateSchemaType] | type[BaseModel] | None = None + update_schema: type[UpdateSchemaType] | type[BaseModel] | None = None schema_out: type[BaseModel] | None = None service: Any = None + pk_type: type[Any] | None = None prefix: str = "" tags: list[str] | None = None @@ -97,6 +100,45 @@ def __init__(self) -> None: self._validate_schema_configurations() self._register_routes() + def _resolve_pk_type(self) -> type[Any]: + """Resolve the effective primary key type from explicit definition or model reflection. + + Returns: + The resolved python type class for the primary key. + """ + if getattr(self, "pk_type", None) is not None: + return self.pk_type + + if getattr(self, "model", None) is not None: + try: + from sqlalchemy import inspect + + mapper = inspect(self.model) + if mapper.primary_key: + pk_col = mapper.primary_key[0] + python_type = getattr(pk_col.type, "python_type", None) + if python_type is not None: + return python_type + except Exception: + pass + + return uuid.UUID + + def _get_pk_path(self, pk_type: type[Any]) -> str: + """Construct the URL path segment matching the primary key converter. + + Args: + pk_type: The resolved primary key type. + + Returns: + The formatted route path string. + """ + if pk_type is int: + return "/{id:int}" + if pk_type is uuid.UUID: + return "/{id:uuid}" + return "/{id}" + def _validate_schema_configurations(self) -> None: """Perform validation checks on configured route schema definitions. @@ -226,6 +268,8 @@ def _register_routes(self) -> None: """Dynamically generate and bind endpoints to the APIRouter.""" service_callable = self.service service_dependency = Depends(Injector(service_callable)) + target_pk_type = self._resolve_pk_type() + pk_path = self._get_pk_path(target_pk_type) if RouteKey.POST not in self.exclude: c_schema = self.create_schema @@ -251,13 +295,13 @@ async def _create_endpoint( if RouteKey.GET not in self.exclude: async def _get_endpoint( - id: uuid.UUID, + id: target_pk_type, service_inst: BaseService = service_dependency, ) -> ResponseWrapper: return await self.get_endpoint(id, service_inst) self.router.add_api_route( - path="/{id:uuid}", + path=pk_path, endpoint=_get_endpoint, methods=["GET"], dependencies=self._get_route_dependencies(RouteKey.GET), @@ -320,14 +364,14 @@ async def _search_endpoint( u_schema = self.update_schema async def _update_endpoint( - id: uuid.UUID, + id: target_pk_type, data_in: u_schema, service_inst: BaseService = service_dependency, ) -> ResponseWrapper: return await self.update_endpoint(id, data_in, service_inst) self.router.add_api_route( - path="/{id:uuid}", + path=pk_path, endpoint=_update_endpoint, methods=["PUT"], dependencies=self._get_route_dependencies(RouteKey.UPDATE), @@ -342,14 +386,14 @@ async def _update_endpoint( u_schema = self.update_schema async def _patch_endpoint( - id: uuid.UUID, + id: target_pk_type, data_in: u_schema, service_inst: BaseService = service_dependency, ) -> ResponseWrapper: return await self.patch_endpoint(id, data_in, service_inst) self.router.add_api_route( - path="/{id:uuid}", + path=pk_path, endpoint=_patch_endpoint, methods=["PATCH"], dependencies=self._get_route_dependencies(RouteKey.PATCH), @@ -363,13 +407,13 @@ async def _patch_endpoint( if RouteKey.DELETE not in self.exclude: async def _delete_endpoint( - id: uuid.UUID, + id: target_pk_type, service_inst: BaseService = service_dependency, ) -> ResponseWrapper: return await self.delete_endpoint(id, service_inst) self.router.add_api_route( - path="/{id:uuid}", + path=pk_path, endpoint=_delete_endpoint, methods=["DELETE"], dependencies=self._get_route_dependencies(RouteKey.DELETE), @@ -381,7 +425,7 @@ async def _delete_endpoint( self._sort_routes() async def create_endpoint( - self, data_in: CreateSchemaType, service: BaseService + self, data_in: Any, service: BaseService ) -> ResponseWrapper: """Execute the POST creation transaction. @@ -396,7 +440,7 @@ async def create_endpoint( return ResponseWrapper(data=data) async def get_endpoint( - self, id: uuid.UUID, service: BaseService + self, id: Any, service: BaseService ) -> ResponseWrapper: """Execute a single-record query lookup. @@ -453,8 +497,8 @@ async def search_endpoint( async def update_endpoint( self, - id: uuid.UUID, - data_in: UpdateSchemaType, + id: Any, + data_in: Any, service: BaseService, ) -> ResponseWrapper: """Execute a full-record entity update transaction. @@ -472,8 +516,8 @@ async def update_endpoint( async def patch_endpoint( self, - id: uuid.UUID, - data_in: UpdateSchemaType, + id: Any, + data_in: Any, service: BaseService, ) -> ResponseWrapper: """Execute a partial record update (PATCH) transaction. @@ -490,7 +534,7 @@ async def patch_endpoint( return ResponseWrapper(data=data) async def delete_endpoint( - self, id: uuid.UUID, service: BaseService + self, id: Any, service: BaseService ) -> ResponseWrapper: """Execute a single-record delete transaction. From 096abbeafd27ff1b17feeb5db3cad1043155e2ea Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 14:39:51 +0330 Subject: [PATCH 26/82] feat(router): implement weighted route specificity sorting - Replaced simple string-based route sorting with hierarchical specificity scoring - Scores static segments (0), dynamic parameters (1), and catch-all paths (2) - Sorts by has_dynamic, total_dynamic score, individual segment scores, then segment length - Prevents static routes like /search from being shadowed by dynamic /{id} routes - Updated docstrings to reflect weighted sorting behavior --- src/zcore/web/base_router.py | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/zcore/web/base_router.py b/src/zcore/web/base_router.py index 5a1c0c0..9cc7683 100644 --- a/src/zcore/web/base_router.py +++ b/src/zcore/web/base_router.py @@ -3,7 +3,7 @@ This module provides the generic `BaseRouter` interface, which scaffolds standard security-aware CRUD endpoints (POST, GET, GET_ALL, SEARCH, UPDATE, PATCH, DELETE) and integrates them with services, schemas, dependency requirements, and pagination handlers, -with clean declarative syntax and zero-configuration primary key auto-detection. +with clean declarative syntax, primary key auto-detection, and weighted route specificity sorting. """ import uuid @@ -48,7 +48,8 @@ class BaseRouter(Generic[CreateSchemaType, UpdateSchemaType]): """Declarative web router orchestrator. Automatically maps CRUD operations to matching database model dependencies and handles - dependency injections, schema validations, and dynamic primary key route resolution. + dependency injections, schema validations, dynamic primary key route resolution, + and hierarchical route ordering based on path specificity. Attributes: model: The database declarative model class. @@ -261,8 +262,27 @@ def _get_route_dependencies(self, route_key: RouteKey) -> list[DependsClass]: return self._normalize_dependencies(dependencies) def _sort_routes(self) -> None: - """Sort routes to place parameterized dynamic paths at the end of the routing table.""" - self.router.routes.sort(key=lambda r: "{" in r.path) + """Sort routes using hierarchical specificity scoring to avoid path shadowing.""" + + def _route_specificity_key(route: Any) -> tuple[int, int, list[int], int]: + path = getattr(route, "path", "") + segments = [seg for seg in path.strip("/").split("/") if seg] + + segment_scores: list[int] = [] + for seg in segments: + if seg.startswith("{") and seg.endswith("}"): + if ":path}" in seg: + segment_scores.append(2) + else: + segment_scores.append(1) + else: + segment_scores.append(0) + + has_dynamic = 1 if any(s > 0 for s in segment_scores) else 0 + total_dynamic = sum(segment_scores) + return (has_dynamic, total_dynamic, segment_scores, -len(segments)) + + self.router.routes.sort(key=_route_specificity_key) def _register_routes(self) -> None: """Dynamically generate and bind endpoints to the APIRouter.""" From 5ea15ad0f8d676760e202bda04c4d180cdf9137b Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 14:42:54 +0330 Subject: [PATCH 27/82] feat(logging): redesign structured logging with ProcessorFormatter - Replaced direct structlog configuration with ProcessorFormatter for standard logging integration - Added custom_processors and log_level parameters for extensibility - Added add_logger_name and StackInfoRenderer to shared processors - Unified root logger with StreamHandler and ProcessorFormatter - Cleared handlers on root and third-party loggers (uvicorn, sqlalchemy) - Configured structlog.stdlib.BoundLogger wrapper_class --- src/zcore/logging/config.py | 80 ++++++++++++++++++++++++++++--------- 1 file changed, 61 insertions(+), 19 deletions(-) diff --git a/src/zcore/logging/config.py b/src/zcore/logging/config.py index 2037050..3cfe351 100644 --- a/src/zcore/logging/config.py +++ b/src/zcore/logging/config.py @@ -1,37 +1,51 @@ """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 structured, +context-aware diagnostics. It dynamically formats output as developer-friendly, +colorized text on interactive terminals or serialized JSON records on production stream targets. """ import logging +import sys +from typing import Any import structlog from zcore.config import settings -def setup_logging() -> None: - """Configure the global structlog logging engine. +def setup_logging( + custom_processors: list[Any] | None = None, + log_level: int | 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. + Configures a unified ProcessorFormatter handler on the root logger, routes all framework, + application, and third-party logs through a shared pipeline, and formats outputs as + colorized console streams in development or JSON streams in production. + + Args: + custom_processors: Optional list of additional structlog processors to include in the pipeline. + log_level: Optional logging level override. """ - for logger_name in ("uvicorn", "uvicorn.access", "sqlalchemy.engine"): - log = logging.getLogger(logger_name) - log.handlers = [] - log.propagate = True + level = log_level or ( + logging.DEBUG if getattr(settings, "DEBUG", False) else 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 custom_processors: + shared_processors.extend(custom_processors) + if getattr(settings, "DEBUG", False): try: from rich.traceback import install @@ -40,14 +54,42 @@ 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, + ], + ) + + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(formatter) + handler.setLevel(level) + + root_logger = logging.getLogger() + root_logger.handlers.clear() + root_logger.addHandler(handler) + root_logger.setLevel(level) + + for logger_name in ( + "uvicorn", + "uvicorn.access", + "uvicorn.error", + "sqlalchemy.engine", + ): + 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 From ac1bcf23f0f5dc89d88c9c3687d87950e302d0b7 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 14:48:38 +0330 Subject: [PATCH 28/82] feat(middleware): enhance request logging with status code and client IP - Added status_code capture from http.response.start ASGI event - Added client_ip extraction from scope client tuple - Changed log event names to 'http_request' and 'http_request_failed' - Added status_code and client_ip fields to both success and error logs - Cleaned up commented code and improved docstrings - Added trailing newline at EOF --- src/zcore/web/middleware.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/zcore/web/middleware.py b/src/zcore/web/middleware.py index 60d0bb7..2b231da 100644 --- a/src/zcore/web/middleware.py +++ b/src/zcore/web/middleware.py @@ -1,7 +1,7 @@ """Web Middleware Implementations. This module provides ASGI middleware to coordinate request lifecycles. It includes -`RequestLogMiddleware` to trace execution durations and manage request correlation headers, +`RequestLogMiddleware` to trace execution durations, status codes, and request correlation headers, and `ScopedDependencyMiddleware` to manage the lifecycle of request-scoped dependency injection container boundaries. """ @@ -28,7 +28,7 @@ class RequestLogMiddleware: Intercepts HTTP requests, extracts or generates correlation IDs, binds them to structured logging contextvars, appends the correlation ID to response headers, - and logs request durations. + and logs request metrics including method, path, status code, client IP, and duration. Attributes: app: The downstream ASGI application instance. @@ -59,7 +59,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: raw_request_id = b"" for name, value in scope.get("headers", []): - if name == b"x-request-id": # Standard lowercased header in ASGI spec + if name == b"x-request-id": raw_request_id = value break @@ -76,34 +76,45 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: structlog.contextvars.bind_contextvars(request_id=request_id) + status_code = 500 + async def send_wrapper(message: dict[str, Any]) -> None: - """Intercept response start and append the request correlation ID header. + """Intercept response start, capture status code, and append correlation header. Args: message: The outgoing ASGI event dictionary. """ + nonlocal status_code if message["type"] == "http.response.start": + status_code = message.get("status", 200) headers = list(message.get("headers", [])) headers.append((b"x-request-id", request_id.encode())) message["headers"] = headers await send(message) token = ctx.initialize() + client = scope.get("client") + client_ip = client[0] if client else "unknown" + try: await self.app(scope, receive, send_wrapper) duration = (time.perf_counter() - s_time) * 1000 log.info( - "http request", + "http_request", method=scope.get("method"), path=scope.get("path"), + status_code=status_code, + client_ip=client_ip, duration_ms=round(duration, 2), ) except Exception: duration = (time.perf_counter() - s_time) * 1000 log.exception( - "http request failed", + "http_request_failed", method=scope.get("method"), path=scope.get("path"), + status_code=status_code, + client_ip=client_ip, duration_ms=round(duration, 2), ) raise @@ -145,13 +156,10 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: scope_id = str(uuid.uuid4()) token = _current_scope_id.set(scope_id) - # Capture background tasks context if integrated with FastAPI - # We defer scope teardown if there are pending background tasks try: async with db_manager.session() as session: container.register_scoped_instance(AsyncSession, session) await self.app(scope, receive, send) finally: - # Safely clear the active IoC scope container.clear_scope(scope_id) - _current_scope_id.reset(token) + _current_scope_id.reset(token) \ No newline at end of file From 40dbf624c6871ca2353dab993ecf9786a6d37938 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 14:55:18 +0330 Subject: [PATCH 29/82] feat(config): add LoggingSettings structured configuration model - Added LoggingSettings with fields: level, json_format, log_sql_queries, slow_query_threshold_ms, file_path, muted_loggers, custom_processors - Supports flexible logging configuration with default muted loggers list --- src/zcore/config.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/zcore/config.py b/src/zcore/config.py index d4f9f5e..589230e 100644 --- a/src/zcore/config.py +++ b/src/zcore/config.py @@ -31,6 +31,23 @@ class DatabaseSettings(BaseModel): 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. From d1fcfc0e2f34eddcd4e338cd1927548fdebcb79f Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 14:55:54 +0330 Subject: [PATCH 30/82] feat(config): integrate LoggingSettings with backward-compatible sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added LOGGING field with default LoggingSettings instance - Added LOG_LEVEL field for legacy compatibility - Updated docstring with logging-related attributes - Renamed _sync_database_settings to _sync_settings - Added sync logic for LOG_LEVEL ↔ LOGGING.level bidirectional fallback --- src/zcore/config.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/zcore/config.py b/src/zcore/config.py index 589230e..685997e 100644 --- a/src/zcore/config.py +++ b/src/zcore/config.py @@ -31,6 +31,7 @@ class DatabaseSettings(BaseModel): 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.""" @@ -49,13 +50,14 @@ class LoggingSettings(BaseModel): ) 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, timezone policies, - 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. @@ -63,6 +65,8 @@ class Settings(BaseSettings): 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. @@ -85,6 +89,9 @@ class Settings(BaseSettings): 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 @@ -99,7 +106,7 @@ class Settings(BaseSettings): DEBUG: bool = True @model_validator(mode="after") - def _sync_database_settings(self) -> "Settings": + 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": @@ -108,6 +115,12 @@ def _sync_database_settings(self) -> "Settings": self.DATABASE.pool_size = self.POOL_SIZE if self.MAX_OVERFLOW != 10 and self.DATABASE.max_overflow == 10: self.DATABASE.max_overflow = self.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 From 3211b6cd78ba808eeb93561a7917b75cf3a03601 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 14:56:34 +0330 Subject: [PATCH 31/82] feat(logging): support LoggingSettings, file rotation, and dictConfig overrides - Added config parameter accepting LoggingSettings or dict for programmatic configuration - Added extra_handlers for custom Python logging handlers - Added dict_config for full logging.config.dictConfig override - Integrated RotatingFileHandler with file_path setting (10MB, 5 backups) - Added json_format toggle with DEBUG fallback - Muted loggers now controlled by cfg.muted_loggers - Migrated to structlog.stdlib.BoundLogger with ProcessorFormatter --- src/zcore/logging/config.py | 101 ++++++++++++++++++++++++++---------- 1 file changed, 75 insertions(+), 26 deletions(-) diff --git a/src/zcore/logging/config.py b/src/zcore/logging/config.py index 3cfe351..55cce0f 100644 --- a/src/zcore/logging/config.py +++ b/src/zcore/logging/config.py @@ -1,37 +1,61 @@ """ZCore Structured Logging Configuration. This module initializes the application's logging pipeline. It integrates standard -Python `logging` with `structlog` via ProcessorFormatter 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( + config: LoggingSettings | dict[str, Any] | None = None, custom_processors: list[Any] | None = None, - log_level: int | 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. - Configures a unified ProcessorFormatter handler on the root logger, routes all framework, - application, and third-party logs through a shared pipeline, and formats outputs as - colorized console streams in development or JSON streams in production. + Supports zero-code declarative settings from `.env`, code-level handler and processor injections, + and complete `logging.config.dictConfig` overrides. Args: - custom_processors: Optional list of additional structlog processors to include in the pipeline. - log_level: Optional logging level override. + 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. """ - level = log_level or ( - logging.DEBUG if getattr(settings, "DEBUG", False) else logging.INFO - ) + 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, @@ -43,10 +67,20 @@ def setup_logging( structlog.processors.format_exc_info, ] + if cfg.custom_processors: + shared_processors.extend(cfg.custom_processors) if custom_processors: shared_processors.extend(custom_processors) - if getattr(settings, "DEBUG", False): + 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 @@ -54,8 +88,6 @@ def setup_logging( except ImportError: pass renderer = structlog.dev.ConsoleRenderer(colors=True) - else: - renderer = structlog.processors.JSONRenderer() formatter = structlog.stdlib.ProcessorFormatter( foreign_pre_chain=shared_processors, @@ -65,21 +97,38 @@ def setup_logging( ], ) - handler = logging.StreamHandler(sys.stdout) - handler.setFormatter(formatter) - handler.setLevel(level) + 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() - root_logger.addHandler(handler) + for h in handlers: + root_logger.addHandler(h) root_logger.setLevel(level) - for logger_name in ( - "uvicorn", - "uvicorn.access", - "uvicorn.error", - "sqlalchemy.engine", - ): + for logger_name in cfg.muted_loggers: log = logging.getLogger(logger_name) log.handlers.clear() log.propagate = True From 44f48aa77cfd7e1626b0378874772dc7c5f4e5b3 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 14:58:23 +0330 Subject: [PATCH 32/82] feat(db): add query logging filters for log_sql_queries and slow_query_threshold - Added check for LOGGING.log_sql_queries to conditionally skip query logging - Added slow_query_threshold_ms to filter out fast queries from logs - Queries below threshold are silently skipped to reduce log noise --- src/zcore/db/setup.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/zcore/db/setup.py b/src/zcore/db/setup.py index e0fc53f..3be1078 100644 --- a/src/zcore/db/setup.py +++ b/src/zcore/db/setup.py @@ -23,6 +23,7 @@ ) 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 @@ -125,11 +126,23 @@ 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()) ignored_keywords = [ From 4f6f309cb9cded7ea45648f04d6aeeb006222493 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 15:06:59 +0330 Subject: [PATCH 33/82] feat(di): enhance background_task with session usage warning and improved docs - Added _warn_if_closed_session_passed to log warnings when AsyncSession is passed directly to background_task - Updated docstrings for clarity and consistency - Removed commented code and improved formatting --- src/zcore/kernel/di.py | 52 +++++++++++++++++++++++------------------- 1 file changed, 29 insertions(+), 23 deletions(-) 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( From 6ccdf6a1f8a7145ee32e8ea3cc6b954bdd0c2df9 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 15:08:17 +0330 Subject: [PATCH 34/82] docs(middleware): update ScopedDependencyMiddleware docstring - Added mention of asynchronous database session management in class docstring --- src/zcore/web/middleware.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/zcore/web/middleware.py b/src/zcore/web/middleware.py index 2b231da..c8be123 100644 --- a/src/zcore/web/middleware.py +++ b/src/zcore/web/middleware.py @@ -3,7 +3,7 @@ This module provides ASGI middleware to coordinate request lifecycles. It includes `RequestLogMiddleware` to trace execution durations, status codes, and request correlation headers, and `ScopedDependencyMiddleware` to manage the lifecycle of request-scoped dependency injection -container boundaries. +container boundaries and asynchronous database sessions. """ import re @@ -126,8 +126,8 @@ class ScopedDependencyMiddleware: """ASGI middleware to isolate request-scoped dependencies. Initializes a new context scope key on incoming requests, binds it to the DI - context variables, and executes a cleanup sweep of the scope's registered instances - upon connection closure. + context variables, allocates an active database session for the request lifecycle, + and executes a cleanup sweep of the scope's registered instances upon connection closure. Attributes: app: The downstream ASGI application instance. From 4eddd0e331722a60d84e0428a7ce8a818acb0d7f Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 15:11:49 +0330 Subject: [PATCH 35/82] feat(cache): add safe eviction task scheduling with event loop detection - Added _ensure_eviction_task() with RuntimeError fallback for missing event loop - Moved eviction task scheduling to init_cache and cache operations (set/get/delete) - Added contextlib.suppress for graceful cancellation in close_cache - Updated docstrings to clarify event loop safety behavior --- src/zcore/cache/base.py | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) 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 From ecde69c79bbed8359bb67f36ac22dad7c7df654e Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 15:22:41 +0330 Subject: [PATCH 36/82] refactor(cli): update templates with latest framework patterns - Updated MAIN_PY_TEMPLATE: use settings.DATABASE config, simplify imports, add version to root endpoint - Updated ENV_TEMPLATE: add TIMEZONE setting, reorder sections - Updated MODEL_TEMPLATE: import uuid, remove TODO comments - Updated SCHEMA_TEMPLATE: remove TODO comments - Updated REPOSITORY_TEMPLATE: import schemas - Updated ROUTER_TEMPLATE: use BaseRouter without generics, fix service reference, simplify docstring - Updated PLUGIN_TEMPLATE: remove empty placeholder methods, clean formatting - Updated TEST_TEMPLATE: reorder imports --- src/zcore/cli/templates.py | 82 +++++++++++--------------------------- 1 file changed, 24 insertions(+), 58 deletions(-) diff --git a/src/zcore/cli/templates.py b/src/zcore/cli/templates.py index a611828..dc204dd 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-beta.9", "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,35 +165,23 @@ 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 schema_out = {ModelName}Response - service = {ModelName}Service + service = {ModelNameService} 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(): From 603e846df39076cab7f4ae5d09a89334d1eeaa59 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 15:23:26 +0330 Subject: [PATCH 37/82] refactor(cli): add docstrings and bump version to 0.1.0-beta.9 - Added module and function docstrings in ui.py - Updated CLI banner version from v0.1.0-Beta to v0.1.0-beta.9 --- src/zcore/cli/ui.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/zcore/cli/ui.py b/src/zcore/cli/ui.py index e9a2b1a..ea595c5 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-beta.9[/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 From bf3437b51b4d20564350ebff8f1598b662df46c3 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 15:24:18 +0330 Subject: [PATCH 38/82] refactor(cli): add module-level and function docstrings, bump version - Added docstrings to CLI module, all prompt functions, and main entrypoint - Bumped version from 0.1.0-Beta to 0.1.0-beta.9 in version output --- src/zcore/cli/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/zcore/cli/__init__.py b/src/zcore/cli/__init__.py index 504c290..5e22325 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-beta.9[/bold white]") sys.exit(0) if args.command == "init": From 39fca596fa7e15ec9ef41702b71987b1ba2936ab Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 15:27:05 +0330 Subject: [PATCH 39/82] feat(core): expand __init__.py exports and modernize datetime imports - Added comprehensive lazy-loading for all major framework components - Exported DatabaseSettings, LoggingSettings, Settings, and all pagination classes - Exported AbstractRepository, AbstractService, BasePermission, HasScopes, UserProtocol - Exported timezone utilities (ZDateTime, now, utc_now, to_app_timezone, etc.) - Exported StorageProvider and get_storage_provider - Exported json_dumps, json_loads, slugify, CustomJSONEncoder - Replaced datetime.timezone.utc with UTC alias from datetime module - Added module docstring and improved __getattr__ docs --- src/zcore/__init__.py | 243 ++++++++++++++++++++++++++++++++++-- src/zcore/utils/timezone.py | 6 +- 2 files changed, 238 insertions(+), 11 deletions(-) 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/utils/timezone.py b/src/zcore/utils/timezone.py index 7885320..5d88f58 100644 --- a/src/zcore/utils/timezone.py +++ b/src/zcore/utils/timezone.py @@ -8,7 +8,7 @@ import functools import zoneinfo -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Annotated from pydantic import PlainSerializer @@ -46,7 +46,7 @@ def utc_now() -> datetime: Returns: Current datetime instance bound to UTC. """ - return datetime.now(timezone.utc) + return datetime.now(UTC) def to_app_timezone(dt: datetime | None) -> datetime | None: @@ -66,7 +66,7 @@ def to_app_timezone(dt: datetime | None) -> datetime | None: target_tz = get_app_timezone() if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) + dt = dt.replace(tzinfo=UTC) return dt.astimezone(target_tz) From 329899eaecf4cd98d8378804a993fed93ce2a0c6 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 15:30:48 +0330 Subject: [PATCH 40/82] feat(kernel): add automatic database and cache cleanup on application shutdown - Added close_cache() and db_manager.close() to lifespan finally block - Ensures guaranteed resource disposal upon application termination - Updated docstring to reflect shutdown resource cleanup behavior --- src/zcore/kernel/engine.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 From db0873b9636400774a7a98977841495ed57a5416 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 15:42:11 +0330 Subject: [PATCH 41/82] refactor: lazy import app_exception_handler and pagination classes - Added lazy __getattr__ for app_exception_handler in exceptions.__init__ to avoid circular imports - Moved pagination class imports back to TYPE_CHECKING block in base_router - Added local inline imports for PaginatedResult, CursorParams, PageNumberParams in endpoint methods --- src/zcore/exceptions/__init__.py | 17 ++++++++++++++++- src/zcore/web/base_router.py | 19 +++++++++++-------- 2 files changed, 27 insertions(+), 9 deletions(-) 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/web/base_router.py b/src/zcore/web/base_router.py index 9cc7683..d27a9f5 100644 --- a/src/zcore/web/base_router.py +++ b/src/zcore/web/base_router.py @@ -8,19 +8,13 @@ import uuid from enum import StrEnum -from typing import Any, Generic, TypeVar +from typing import TYPE_CHECKING, Any, Generic, TypeVar from fastapi import APIRouter, Depends, status from fastapi.params import Depends as DependsClass from fastapi.routing import APIRoute from pydantic import BaseModel -from zcore.db.pagination import ( - BasePagination, - CursorParams, - PageNumberParams, - PaginatedResult, -) from zcore.db.search import SearchRequest from zcore.kernel.di import Injector from zcore.security.permissions import HasScopes @@ -28,6 +22,9 @@ from zcore.web.api_router import ZCoreAPIRoute from zcore.web.response import ResponseWrapper +if TYPE_CHECKING: + from zcore.db.pagination import BasePagination + CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) @@ -76,7 +73,7 @@ class BaseRouter(Generic[CreateSchemaType, UpdateSchemaType]): prefix: str = "" tags: list[str] | None = None exclude: set[RouteKey] | None = None - pagination_class: type[BasePagination] | None = None + pagination_class: type["BasePagination"] | None = None route_class: type[APIRoute] = ZCoreAPIRoute expose_schemas: set[RouteKey] | bool = False @@ -487,6 +484,8 @@ async def get_all_endpoint( The list of resolved model records wrapped in a ResponseWrapper. """ result = await service.get_list(pagination) + from zcore.db.pagination import PaginatedResult + if isinstance(result, PaginatedResult): return ResponseWrapper(data=result.data, meta=result.meta) return ResponseWrapper(data=result) @@ -505,12 +504,16 @@ async def search_endpoint( """ pagination = None if self.pagination_class: + from zcore.db.pagination import CursorParams, PageNumberParams + if self.pagination_class.params_class == CursorParams: pagination = CursorParams(cursor=search_in.cursor, size=search_in.size) else: pagination = PageNumberParams(page=search_in.page, size=search_in.size) result = await service.search(search_in, pagination) + from zcore.db.pagination import PaginatedResult + if isinstance(result, PaginatedResult): return ResponseWrapper(data=result.data, meta=result.meta) return ResponseWrapper(data=result) From 3b78648304f5408dc1e999735803d46dc7ec62c1 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 15:46:06 +0330 Subject: [PATCH 42/82] fix(cli): correct service class reference in router template - Fixed {ModelNameService} to {ModelName}Service in ROUTER_TEMPLATE --- src/zcore/cli/templates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zcore/cli/templates.py b/src/zcore/cli/templates.py index dc204dd..97455de 100644 --- a/src/zcore/cli/templates.py +++ b/src/zcore/cli/templates.py @@ -174,7 +174,7 @@ class {ModelName}Router(BaseRouter): create_schema = {ModelName}Create update_schema = {ModelName}Update schema_out = {ModelName}Response - service = {ModelNameService} + service = {ModelName}Service prefix = "/{app_name}" tags = ["{ModelName}"] From 507d49155196c04d07a79d76b2305253b81cac88 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 15:47:53 +0330 Subject: [PATCH 43/82] refactor(timezone): simplify timezone resolution with UTC fallback - Replaced ZoneInfo fallback with timezone.utc for UTC case and exceptions - Added early return for TIMEZONE='UTC' to avoid ZoneInfo overhead - Simplified return type to Any (timezone/ZoneInfo union) --- src/zcore/utils/timezone.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/zcore/utils/timezone.py b/src/zcore/utils/timezone.py index 5d88f58..207dcc0 100644 --- a/src/zcore/utils/timezone.py +++ b/src/zcore/utils/timezone.py @@ -1,15 +1,13 @@ """ZCore Dynamic Timezone and DateTime Management Module. This module provides timezone-aware datetime utilities leveraging Python's -standard `zoneinfo.ZoneInfo` (IANA database). It coordinates application-level -timezone conversions, timezone-aware current time generation, and automated -ISO 8601 formatting with offsets. +standard `zoneinfo.ZoneInfo` (IANA database) with built-in fallback to `datetime.timezone.utc`. """ import functools import zoneinfo -from datetime import UTC, datetime -from typing import Annotated +from datetime import datetime, timezone +from typing import Annotated, Any from pydantic import PlainSerializer @@ -17,18 +15,19 @@ @functools.lru_cache(maxsize=16) -def get_app_timezone() -> zoneinfo.ZoneInfo: +def get_app_timezone() -> Any: """Retrieve and cache the active application ZoneInfo object based on settings. Returns: - A ZoneInfo instance representing the application's configured timezone, - falling back to UTC if the configured identifier is invalid. + A timezone instance representing the application's configured timezone. """ tz_name = getattr(settings, "TIMEZONE", "UTC") + if not tz_name or str(tz_name).upper() == "UTC": + return timezone.utc try: return zoneinfo.ZoneInfo(tz_name) except Exception: - return zoneinfo.ZoneInfo("UTC") + return timezone.utc def now() -> datetime: @@ -46,7 +45,7 @@ def utc_now() -> datetime: Returns: Current datetime instance bound to UTC. """ - return datetime.now(UTC) + return datetime.now(timezone.utc) def to_app_timezone(dt: datetime | None) -> datetime | None: @@ -66,7 +65,7 @@ def to_app_timezone(dt: datetime | None) -> datetime | None: target_tz = get_app_timezone() if dt.tzinfo is None: - dt = dt.replace(tzinfo=UTC) + dt = dt.replace(tzinfo=timezone.utc) return dt.astimezone(target_tz) From a32412de4a1e125a84810c7045343a3dc2540957 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 16:09:35 +0330 Subject: [PATCH 44/82] feat(timezone): add structlog warning for invalid timezone configuration - Added logger warning when TIMEZONE setting is invalid and UTC fallback is used - Switched to datetime.UTC alias for consistency - Re-added docstring note about UTC fallback --- src/zcore/utils/timezone.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/zcore/utils/timezone.py b/src/zcore/utils/timezone.py index 207dcc0..4081910 100644 --- a/src/zcore/utils/timezone.py +++ b/src/zcore/utils/timezone.py @@ -6,28 +6,37 @@ import functools import zoneinfo -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Annotated, Any +import structlog from pydantic import PlainSerializer from zcore.config import settings +logger = structlog.get_logger() + @functools.lru_cache(maxsize=16) def get_app_timezone() -> Any: """Retrieve and cache the active application ZoneInfo object based on settings. Returns: - A timezone instance representing the application's configured timezone. + A timezone instance representing the application's configured timezone, + falling back to UTC if the configured identifier is invalid. """ tz_name = getattr(settings, "TIMEZONE", "UTC") if not tz_name or str(tz_name).upper() == "UTC": - return timezone.utc + return UTC try: return zoneinfo.ZoneInfo(tz_name) - except Exception: - return timezone.utc + except Exception as e: + logger.warning( + "Invalid timezone configured, falling back to UTC", + timezone=tz_name, + error=str(e), + ) + return UTC def now() -> datetime: @@ -45,7 +54,7 @@ def utc_now() -> datetime: Returns: Current datetime instance bound to UTC. """ - return datetime.now(timezone.utc) + return datetime.now(UTC) def to_app_timezone(dt: datetime | None) -> datetime | None: @@ -65,7 +74,7 @@ def to_app_timezone(dt: datetime | None) -> datetime | None: target_tz = get_app_timezone() if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) + dt = dt.replace(tzinfo=UTC) return dt.astimezone(target_tz) From b826fc5d194c864cbad4d9450e7c8479f9f70658 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 16:15:57 +0330 Subject: [PATCH 45/82] test(exceptions): use timezone-aware ISO format in test assertions - Added import for format_iso_with_app_timezone - Replaced now.isoformat() with format_iso_with_app_timezone(now) for consistent timezone handling - Added missing blank lines between test functions --- tests/test_exceptions.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index b32fdcc..f0a26c3 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -17,12 +17,14 @@ ValidationError, ) from zcore.exceptions.handlers import app_exception_handler +from zcore.utils.timezone import format_iso_with_app_timezone from zcore.web.response import ResponseWrapper class CustomPaymentFailed(AppException): status_code = 402 + @pytest.mark.parametrize( "exc_class, status_code, message, payload", [ @@ -45,6 +47,7 @@ def test_exception_status_codes( assert exc.message == message assert exc.payload == payload + @pytest.mark.anyio @pytest.mark.parametrize( "exc_to_raise, expected_status, expected_msg, expected_meta_payload", @@ -79,6 +82,7 @@ def trigger_error() -> None: assert body["meta"]["error_type"] == exc_to_raise.__class__.__name__ assert body["meta"]["payload"] == expected_meta_payload + @pytest.mark.anyio async def test_exception_logging_integration() -> None: app = FastAPI() @@ -102,6 +106,7 @@ def trigger_log() -> None: method="GET" ) + @pytest.mark.anyio async def test_custom_app_exception_subclass() -> None: app = FastAPI() @@ -121,6 +126,7 @@ def trigger_payment_error() -> None: assert body["meta"]["error_type"] == "CustomPaymentFailed" assert body["meta"]["payload"] == {"reason": "insufficient_funds"} + @pytest.mark.anyio async def test_complex_payload_serialization() -> None: app = FastAPI() @@ -140,9 +146,10 @@ def trigger_complex() -> None: assert response.status_code == 400 body = response.json() assert body["meta"]["payload"]["uuid"] == str(uid) - assert body["meta"]["payload"]["time"] == now.isoformat() + assert body["meta"]["payload"]["time"] == format_iso_with_app_timezone(now) assert body["meta"]["payload"]["decimal"] == "123.45" + @pytest.mark.anyio async def test_boundary_null_values() -> None: app = FastAPI() @@ -161,6 +168,7 @@ def trigger_null() -> None: assert body["message"] == "" assert body["meta"]["payload"] is None + @pytest.mark.anyio async def test_strict_response_wrapper_compliance() -> None: app = FastAPI() From 1c994be9bef8e2ea137ce26e797ac4f911703151 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 16:16:59 +0330 Subject: [PATCH 46/82] test(logging): update tests for ProcessorFormatter and LOG_LEVEL settings - Replaced structlog.configure processor assertions with root logger handler formatter checks - Updated processor type assertions to use structlog.stdlib.add_log_level - Changed log level tests to use LOG_LEVEL setting instead of DEBUG flag - Added WARNING level test coverage - Refactored log_filtering test to use standard logging handlers instead of structlog BoundLogger - Added missing blank lines between test functions --- tests/test_logging.py | 48 +++++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/tests/test_logging.py b/tests/test_logging.py index 746f058..43c515a 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -31,9 +31,11 @@ def test_logging_format_by_environment( with patch("structlog.configure") as mock_configure: logging_config.setup_logging() mock_configure.assert_called_once() - configured_processors = mock_configure.call_args[1].get("processors", []) - assert len(configured_processors) > 0 - assert isinstance(configured_processors[-1], expected_renderer_cls) + root_logger = logging.getLogger() + assert len(root_logger.handlers) > 0 + formatter = root_logger.handlers[0].formatter + assert isinstance(formatter.processors[-1], expected_renderer_cls) + def test_suppress_third_party_duplicate_handlers(monkeypatch: pytest.MonkeyPatch) -> None: loggers = ["uvicorn", "uvicorn.access", "sqlalchemy.engine"] @@ -55,6 +57,7 @@ def test_suppress_third_party_duplicate_handlers(monkeypatch: pytest.MonkeyPatch assert len(logger.handlers) == 0 assert logger.propagate is True + @pytest.mark.parametrize("rich_installed", [True, False]) def test_rich_integration_debug_mode(monkeypatch: pytest.MonkeyPatch, rich_installed: bool) -> None: monkeypatch.setattr(settings, "DEBUG", True) @@ -85,6 +88,7 @@ def mock_import(name: str, *args: Any, **kwargs: Any) -> Any: else: mock_install.assert_not_called() + def test_shared_processors_chain_integrity(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(settings, "DEBUG", False) @@ -96,46 +100,49 @@ def test_shared_processors_chain_integrity(monkeypatch: pytest.MonkeyPatch) -> N configured_processors = mock_configure.call_args[1].get("processors", []) assert structlog.contextvars.merge_contextvars in configured_processors - assert structlog.processors.add_log_level in configured_processors + assert structlog.stdlib.add_log_level in configured_processors assert structlog.processors.format_exc_info in configured_processors assert any(isinstance(p, structlog.stdlib.PositionalArgumentsFormatter) for p in configured_processors) assert any(isinstance(p, structlog.processors.TimeStamper) for p in configured_processors) + @pytest.mark.parametrize( - "debug_flag, expected_level", + "log_level_setting, expected_level", [ - (True, logging.DEBUG), - (False, logging.INFO), + ("DEBUG", logging.DEBUG), + ("INFO", logging.INFO), + ("WARNING", logging.WARNING), ] ) def test_dynamic_log_level_configuration( monkeypatch: pytest.MonkeyPatch, - debug_flag: bool, + log_level_setting: str, expected_level: int ) -> None: - monkeypatch.setattr(settings, "DEBUG", debug_flag) + monkeypatch.setattr(settings, "LOG_LEVEL", log_level_setting) import zcore.logging.config as logging_config importlib.reload(logging_config) - with patch("structlog.configure"), \ - patch("structlog.make_filtering_bound_logger") as mock_make_filter: - - logging_config.setup_logging() - mock_make_filter.assert_called_once_with(expected_level) + with patch("structlog.configure"): + logging_config.setup_logging(log_level=log_level_setting) + assert logging.getLogger().level == expected_level + def test_log_filtering_enforcement_in_action() -> None: - filtering_bound_logger_cls = structlog.make_filtering_bound_logger(logging.INFO) - mock_underlying = MagicMock() - logger = filtering_bound_logger_cls(mock_underlying, [], {}) + logger = logging.getLogger("test_filter_logger") + logger.setLevel(logging.INFO) + mock_handler = MagicMock() + logger.handlers = [mock_handler] logger.info("info_msg") - assert mock_underlying.info.called + assert mock_handler.handle.called - mock_underlying.reset_mock() + mock_handler.reset_mock() logger.debug("debug_msg") - assert not mock_underlying.debug.called + assert not mock_handler.handle.called + def test_contextvars_binding_verification() -> None: structlog.contextvars.clear_contextvars() @@ -146,6 +153,7 @@ def test_contextvars_binding_verification() -> None: structlog.contextvars.clear_contextvars() + def test_factory_and_caching_configurations(monkeypatch: pytest.MonkeyPatch) -> None: import zcore.logging.config as logging_config importlib.reload(logging_config) From 0462b07a28c66a019f6590cf60ad6dc006f4eb5c Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 16:18:23 +0330 Subject: [PATCH 47/82] test(search): add missing blank lines between test functions - Added blank lines between all test functions for consistent formatting - Fixed assertion message in between operator validation test to match new error message --- tests/test_search.py | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/tests/test_search.py b/tests/test_search.py index 02b0ee0..c997311 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -22,6 +22,7 @@ class SearchUser(Base): is_active = Column(Boolean) uid = Column(Uuid) + class SearchProfile(Base): __tablename__ = f"search_profiles_{uuid_pkg.uuid4().hex[:6]}" id = Column(Integer, primary_key=True) @@ -30,6 +31,7 @@ class SearchProfile(Base): user = relationship("SearchUser") posts = relationship("SearchPost", back_populates="profile") + class SearchPost(Base): __tablename__ = f"search_posts_{uuid_pkg.uuid4().hex[:6]}" id = Column(Integer, primary_key=True) @@ -37,6 +39,7 @@ class SearchPost(Base): title = Column(String) profile = relationship("SearchProfile", back_populates="posts") + class SearchComment(Base): __tablename__ = f"search_comments_{uuid_pkg.uuid4().hex[:6]}" id = Column(Integer, primary_key=True) @@ -44,12 +47,14 @@ class SearchComment(Base): body = Column(String) post = relationship("SearchPost") + class CustomDepthEntity(Base): __tablename__ = f"custom_depth_{uuid_pkg.uuid4().hex[:6]}" __max_search_depth__ = 5 id = Column(Integer, primary_key=True) username = Column(String) + @pytest.fixture(autouse=True) def mock_restricted_fields() -> Generator[None, None, None]: from zcore.context.context import ctx @@ -57,6 +62,7 @@ def mock_restricted_fields() -> Generator[None, None, None]: yield ctx.restricted_fields = None + @pytest_asyncio.fixture(autouse=True) async def setup_search_tables(test_engine: Any) -> AsyncGenerator[None, None]: async with test_engine.begin() as conn: @@ -65,6 +71,7 @@ async def setup_search_tables(test_engine: Any) -> AsyncGenerator[None, None]: async with test_engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) + @pytest_asyncio.fixture async def seed_data(db_session: Any) -> None: user1 = SearchUser( @@ -107,6 +114,7 @@ async def seed_data(db_session: Any) -> None: await db_session.flush() + @pytest.mark.parametrize( "op, val, expected_ids", [ @@ -134,6 +142,7 @@ async def test_search_all_operators( assert len(items) == len(expected_ids) assert {item.id for item in items} == set(expected_ids) + @pytest.mark.anyio async def test_search_startswith_operator(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -150,6 +159,7 @@ async def test_search_startswith_operator(db_session: Any, seed_data: None) -> N res_none = await db_session.execute(engine.build_base_query(req_none)) assert len(res_none.scalars().all()) == 0 + @pytest.mark.anyio async def test_search_endswith_operator(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -166,6 +176,7 @@ async def test_search_endswith_operator(db_session: Any, seed_data: None) -> Non res_st = await db_session.execute(engine.build_base_query(req_st)) assert {u.id for u in res_st.scalars().all()} == {3} + @pytest.mark.anyio async def test_search_contains_operator(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -182,6 +193,7 @@ async def test_search_contains_operator(db_session: Any, seed_data: None) -> Non res_ues = await db_session.execute(engine.build_base_query(req_ues)) assert {u.id for u in res_ues.scalars().all()} == {3} + @pytest.mark.anyio async def test_search_between_operator_numbers(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -194,6 +206,7 @@ async def test_search_between_operator_numbers(db_session: Any, seed_data: None) res_2_3 = await db_session.execute(engine.build_base_query(req_2_3)) assert {u.id for u in res_2_3.scalars().all()} == {2, 3} + @pytest.mark.anyio async def test_search_between_operator_datetime(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -210,6 +223,7 @@ async def test_search_between_operator_datetime(db_session: Any, seed_data: None res_dt = await db_session.execute(engine.build_base_query(req_dt)) assert {u.id for u in res_dt.scalars().all()} == {1, 2} + @pytest.mark.parametrize( "invalid_val", [ @@ -224,13 +238,15 @@ def test_search_between_validation_error(invalid_val: Any) -> None: req = SearchRequest(filters=[FilterItem(field="id", op="between", value=invalid_val)]) with pytest.raises(ValidationError) as exc_info: engine.build_base_query(req) - assert "expects a list of 2 elements" in str(exc_info.value) + assert "requires a list of exactly 2 items" in str(exc_info.value) + def make_nested_filter(depth: int, field_name: str = "username") -> FilterItem: if depth <= 1: return FilterItem(field=field_name, op="eq", value="guest") return FilterItem(op="and", items=[make_nested_filter(depth - 1, field_name)]) + @pytest.mark.parametrize( "depth, should_raise", [ @@ -252,6 +268,7 @@ def test_search_max_filter_depth_default(depth: int, should_raise: bool) -> None query = engine.build_base_query(request) assert query is not None + @pytest.mark.parametrize( "depth, should_raise", [ @@ -273,6 +290,7 @@ def test_search_custom_model_max_depth(depth: int, should_raise: bool) -> None: query = engine.build_base_query(request) assert query is not None + @pytest.mark.parametrize( "field_variant", [ @@ -292,6 +310,7 @@ def test_search_restricted_field_bypass(field_variant: str) -> None: engine.build_base_query(sort_req) assert "restricted" in str(exc_info.value).lower() + @pytest.mark.parametrize( "paths, expected_error, error_message", [ @@ -317,6 +336,7 @@ def test_search_include_depth_and_relation( query = engine.build_base_query(request) assert query is not None + @pytest.mark.anyio async def test_search_lt_le_ge(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -333,6 +353,7 @@ async def test_search_lt_le_ge(db_session: Any, seed_data: None) -> None: res_ge = await db_session.execute(engine.build_base_query(req_ge)) assert {u.id for u in res_ge.scalars().all()} == {2, 3} + @pytest.mark.anyio async def test_search_in_operator(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -340,6 +361,7 @@ async def test_search_in_operator(db_session: Any, seed_data: None) -> None: res = await db_session.execute(engine.build_base_query(req)) assert {u.id for u in res.scalars().all()} == {1, 3} + @pytest.mark.anyio async def test_search_is_null_operator(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchProfile) @@ -352,6 +374,7 @@ async def test_search_is_null_operator(db_session: Any, seed_data: None) -> None res_not_null = await db_session.execute(engine.build_base_query(req_not_null)) assert {p.id for p in res_not_null.scalars().all()} == {1, 3} + @pytest.mark.anyio async def test_search_wildcard_escaping(db_session: Any, seed_data: None) -> None: user4 = SearchUser( @@ -376,6 +399,7 @@ async def test_search_wildcard_escaping(db_session: Any, seed_data: None) -> Non results = list(res_underscore.scalars().all()) assert {u.id for u in results} == {2} + @pytest.mark.anyio async def test_search_type_coercion_date_time(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -383,6 +407,7 @@ async def test_search_type_coercion_date_time(db_session: Any, seed_data: None) res = await db_session.execute(engine.build_base_query(req)) assert {u.id for u in res.scalars().all()} == {2} + @pytest.mark.anyio async def test_search_type_coercion_uuid(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -390,6 +415,7 @@ async def test_search_type_coercion_uuid(db_session: Any, seed_data: None) -> No res = await db_session.execute(engine.build_base_query(req)) assert {u.id for u in res.scalars().all()} == {3} + @pytest.mark.anyio async def test_search_type_coercion_bool(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -397,6 +423,7 @@ async def test_search_type_coercion_bool(db_session: Any, seed_data: None) -> No res_true = await db_session.execute(engine.build_base_query(req_true)) assert {u.id for u in res_true.scalars().all()} == {1, 3} + @pytest.mark.anyio async def test_search_relation_one_to_one(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchProfile) @@ -404,6 +431,7 @@ async def test_search_relation_one_to_one(db_session: Any, seed_data: None) -> N res = await db_session.execute(engine.build_base_query(req)) assert {p.id for p in res.scalars().all()} == {3} + @pytest.mark.anyio async def test_search_relation_one_to_many_any(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchProfile) @@ -411,6 +439,7 @@ async def test_search_relation_one_to_many_any(db_session: Any, seed_data: None) res = await db_session.execute(engine.build_base_query(req)) assert {p.id for p in res.scalars().all()} == {3} + @pytest.mark.anyio async def test_search_custom_handler(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -419,6 +448,7 @@ async def test_search_custom_handler(db_session: Any, seed_data: None) -> None: res = await db_session.execute(engine.build_base_query(req)) assert {u.id for u in res.scalars().all()} == {3} + def test_search_nested_restricted_field() -> None: from zcore.context.context import ctx ctx.restricted_fields = {"post.profile.user.password"} @@ -428,6 +458,7 @@ def test_search_nested_restricted_field() -> None: engine.build_base_query(req) assert "restricted" in str(exc_info.value).lower() + def test_search_restricted_relationship_path() -> None: from zcore.context.context import ctx ctx.restricted_fields = {"SearchComment.post", "post"} @@ -437,6 +468,7 @@ def test_search_restricted_relationship_path() -> None: engine.build_base_query(req) assert "restricted" in str(exc_info.value).lower() + def test_search_invalid_sort_fields() -> None: engine = SearchEngine(SearchUser) req = SearchRequest(sort=[SortItem(field="non_existent_field", order="asc")]) @@ -444,6 +476,7 @@ def test_search_invalid_sort_fields() -> None: engine.build_base_query(req) assert "invalid sort field" in str(exc_info.value).lower() + @pytest.mark.anyio async def test_search_pagination_build_query(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -453,6 +486,7 @@ async def test_search_pagination_build_query(db_session: Any, seed_data: None) - items = list(res.scalars().all()) assert len(items) == 1 + @pytest.mark.anyio async def test_search_empty_request(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) @@ -462,6 +496,7 @@ async def test_search_empty_request(db_session: Any, seed_data: None) -> None: items = list(res.scalars().all()) assert len(items) == 3 + @pytest.mark.anyio async def test_search_complex_combined_logical_filters(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) From 9a1f78c72661bc61477f317ede747c473b690d43 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 16:19:43 +0330 Subject: [PATCH 48/82] test(service): update tests for target parameter and add blank lines - Changed pre_update signature to use target instead of id - Changed pre_update_multi data type to dict[ServiceTestModel | Any, BaseModel] - Changed pre_delete signature to use target instead of id - Updated update_multi fake to use getattr(key, 'id', key) for model instance keys - Updated test calls to use target=... instead of id=... - Added missing blank lines between all test functions --- tests/test_service.py | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/tests/test_service.py b/tests/test_service.py index a91f5a8..4cc2405 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -19,12 +19,15 @@ class ServiceTestModel(Base): id = Column(Integer, primary_key=True) name = Column(String) + class ServiceTestCreateSchema(BaseModel): name: str + class ServiceTestUpdateSchema(BaseModel): name: str + class HookTrackingService(BaseService[ServiceTestModel]): def __init__(self, model: type[ServiceTestModel], repository: Any) -> None: super().__init__(model, repository) @@ -62,7 +65,7 @@ async def post_create_multi(self, models: Sequence[ServiceTestModel]) -> None: for m in models: m.name = m.name + "_BULKAUDITED" - async def pre_update(self, id: Any, schema: ServiceTestUpdateSchema, partial: bool) -> dict[str, Any] | None: + async def pre_update(self, target: ServiceTestModel | Any, schema: ServiceTestUpdateSchema, partial: bool) -> dict[str, Any] | None: self.hooks_called.append("pre_update") schema.name = "UPDATED_HOOK_" + schema.name return None @@ -71,7 +74,7 @@ async def post_update(self, model: ServiceTestModel) -> None: self.hooks_called.append("post_update") model.name = model.name + "_UPDATED" - async def pre_update_multi(self, data: dict[Any, BaseModel], partial: bool) -> None: + async def pre_update_multi(self, data: dict[ServiceTestModel | Any, BaseModel], partial: bool) -> None: self.hooks_called.append("pre_update_multi") for _k, v in data.items(): v.name = "BULK_UPD_" + v.name @@ -81,7 +84,7 @@ async def post_update_multi(self, models: Sequence[ServiceTestModel]) -> None: for m in models: m.name = m.name + "_BULKUPDATED" - async def pre_delete(self, id: Any) -> None: + async def pre_delete(self, target: Any) -> None: self.hooks_called.append("pre_delete") async def post_delete(self, model: ServiceTestModel) -> None: @@ -104,6 +107,7 @@ async def post_search(self, models: Sequence[ServiceTestModel]) -> None: for m in models: m.name = m.name + "_SEARCHED" + @pytest.mark.anyio @pytest.mark.parametrize( "initial_name, expected_repo_name, expected_final_name", @@ -138,6 +142,7 @@ async def fake_create(schema: ServiceTestCreateSchema, **extra: Any) -> ServiceT assert result.name == expected_final_name mock_repo.create.assert_called_once() + @pytest.mark.anyio @pytest.mark.parametrize( "uow_managed, should_commit, raise_commit_error", @@ -184,6 +189,7 @@ async def test_service_safe_commit_scenarios( mock_db.commit.assert_not_called() mock_db.rollback.assert_not_called() + @pytest.mark.anyio async def test_service_post_get_hook() -> None: mock_repo = AsyncMock() @@ -196,6 +202,7 @@ async def test_service_post_get_hook() -> None: assert "post_get" in service.hooks_called assert result.name == "Database_GETHOOKED" + @pytest.mark.anyio async def test_service_post_get_multi_hook() -> None: mock_repo = AsyncMock() @@ -209,6 +216,7 @@ async def test_service_post_get_multi_hook() -> None: assert results[0].name == "R1_GETMULTIHOCKED" assert results[1].name == "R2_GETMULTIHOCKED" + @pytest.mark.anyio async def test_service_get_entity_not_found() -> None: mock_repo = AsyncMock() @@ -220,6 +228,7 @@ async def test_service_get_entity_not_found() -> None: assert "post_get" not in service.hooks_called + @pytest.mark.anyio async def test_service_get_list_pagination() -> None: mock_repo = AsyncMock() @@ -235,6 +244,7 @@ async def test_service_get_list_pagination() -> None: assert result.data[1].name == "R2_GETMULTIHOCKED" assert result.meta["page"] == 1 + @pytest.mark.anyio async def test_service_pre_create_extra_data_merge() -> None: mock_db = MagicMock() @@ -255,6 +265,7 @@ async def fake_create(schema: ServiceTestCreateSchema, **extra: Any) -> ServiceT assert "pre_create" in service.hooks_called + @pytest.mark.anyio async def test_service_create_multi_hooks() -> None: mock_db = MagicMock() @@ -276,6 +287,7 @@ async def fake_create_multi(schemas: list[BaseModel], refresh: bool = False) -> assert results[0].name == "BULK_S1_BULKAUDITED" assert results[1].name == "BULK_S2_BULKAUDITED" + @pytest.mark.anyio async def test_service_update_multi_hooks() -> None: mock_db = MagicMock() @@ -283,8 +295,8 @@ async def test_service_update_multi_hooks() -> None: mock_repo = AsyncMock() mock_repo.db = mock_db - async def fake_update_multi(data: dict[Any, BaseModel], partial: bool = False, refresh: bool = False) -> Sequence[ServiceTestModel]: - return [ServiceTestModel(id=k, name=v.name) for k, v in data.items()] + async def fake_update_multi(data: dict[ServiceTestModel | Any, BaseModel], partial: bool = False, refresh: bool = False) -> Sequence[ServiceTestModel]: + return [ServiceTestModel(id=getattr(k, "id", k), name=v.name) for k, v in data.items()] mock_repo.update_multi.side_effect = fake_update_multi @@ -300,6 +312,7 @@ async def fake_update_multi(data: dict[Any, BaseModel], partial: bool = False, r assert results[0].name == "BULK_UPD_U1_BULKUPDATED" assert results[1].name == "BULK_UPD_U2_BULKUPDATED" + @pytest.mark.anyio async def test_service_update_entity_not_found() -> None: mock_repo = AsyncMock() @@ -309,10 +322,11 @@ async def test_service_update_entity_not_found() -> None: schema = ServiceTestUpdateSchema(name="Fresh") with pytest.raises(EntityNotFound): - await service.update(id=999, schema=schema) + await service.update(target=999, schema=schema) assert "post_update" not in service.hooks_called + @pytest.mark.anyio async def test_service_delete_hooks() -> None: mock_db = MagicMock() @@ -324,12 +338,13 @@ async def test_service_delete_hooks() -> None: mock_repo.delete.return_value = record service = HookTrackingService(ServiceTestModel, mock_repo) - result = await service.delete(id=1) + result = await service.delete(target=1) assert "pre_delete" in service.hooks_called assert "post_delete" in service.hooks_called assert result.name == "ToKill_DELETED" + @pytest.mark.anyio async def test_service_delete_multi_hooks() -> None: mock_db = MagicMock() @@ -348,6 +363,7 @@ async def test_service_delete_multi_hooks() -> None: assert results[0].name == "K1_BULKDELETED" assert results[1].name == "K2_BULKDELETED" + @pytest.mark.anyio async def test_service_delete_entity_not_found() -> None: mock_repo = AsyncMock() @@ -356,10 +372,11 @@ async def test_service_delete_entity_not_found() -> None: service = HookTrackingService(ServiceTestModel, mock_repo) with pytest.raises(EntityNotFound): - await service.delete(id=999) + await service.delete(target=999) assert "post_delete" not in service.hooks_called + @pytest.mark.anyio async def test_service_search_hooks() -> None: mock_repo = AsyncMock() @@ -375,6 +392,7 @@ async def test_service_search_hooks() -> None: assert results[0].name == "Found1_SEARCHED" assert results[1].name == "Found2_SEARCHED" + @pytest.mark.anyio async def test_service_search_pagination() -> None: mock_repo = AsyncMock() @@ -392,6 +410,7 @@ async def test_service_search_pagination() -> None: assert result.data[1].name == "Found2_SEARCHED" assert result.meta["page"] == 1 + @pytest.mark.anyio async def test_service_post_create_error_rollback() -> None: mock_db = AsyncMock() From 8824d4562127cd165d696fc2c4c62532ed54d970 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 16:20:48 +0330 Subject: [PATCH 49/82] test(utils): update datetime assertions for timezone-aware ISO formatting - Updated datetime serialization test to expect +00:00 UTC offset - Updated nested datetime assertion to include +00:00 UTC offset - Added blank lines between all test functions --- tests/test_utils.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index ee9a401..e20a22f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -13,9 +13,11 @@ 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 +27,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 +51,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 +76,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 +99,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 +113,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 +179,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 +200,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 +209,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 +231,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", From ccc3096b6b7d64a7d465978c883ccddf75f99ac3 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 16:25:28 +0330 Subject: [PATCH 50/82] test(logging): replace MagicMock handler with real DummyHandler - Added DummyHandler class extending logging.Handler to capture log records - Uses real handler instead of MagicMock for more reliable assertions - Verifies record count and message content for both info and debug levels --- tests/test_logging.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/test_logging.py b/tests/test_logging.py index 43c515a..e3cb0da 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -131,17 +131,26 @@ def test_dynamic_log_level_configuration( def test_log_filtering_enforcement_in_action() -> None: + class DummyHandler(logging.Handler): + def __init__(self) -> None: + super().__init__() + self.records: list[logging.LogRecord] = [] + + def emit(self, record: logging.LogRecord) -> None: + self.records.append(record) + logger = logging.getLogger("test_filter_logger") logger.setLevel(logging.INFO) - mock_handler = MagicMock() - logger.handlers = [mock_handler] - + logger.propagate = False + handler = DummyHandler() + logger.handlers = [handler] + logger.info("info_msg") - assert mock_handler.handle.called - - mock_handler.reset_mock() + assert len(handler.records) == 1 + assert handler.records[0].getMessage() == "info_msg" + logger.debug("debug_msg") - assert not mock_handler.handle.called + assert len(handler.records) == 1 def test_contextvars_binding_verification() -> None: From f9dbd5196acc6a7a48e7392244b3b04427742e0f Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 16:40:11 +0330 Subject: [PATCH 51/82] test(config): add comprehensive tests for DatabaseSettings, LoggingSettings, and sync validators - Added test for DatabaseSettings defaults and custom values - Added test for LoggingSettings defaults and custom values - Added test for bidirectional sync between flat DATABASE_URL/POOL_SIZE/MAX_OVERFLOW and nested DATABASE - Added test for bidirectional sync between LOG_LEVEL and LOGGING.level - Added test for TIMEZONE and AUTO_CONVERT_TIMEZONE defaults --- tests/test_config.py | 135 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 133 insertions(+), 2 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index 04c84d5..f83fa28 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,7 +4,14 @@ import pytest -from zcore.config import Settings, get_settings, initialize_settings, settings +from zcore.config import ( + DatabaseSettings, + LoggingSettings, + Settings, + get_settings, + initialize_settings, + settings, +) from zcore.kernel.di import container @@ -28,6 +35,7 @@ def test_settings_environmental_loading( assert getattr(fresh_settings, check_attr) == expected_val assert fresh_settings.DATABASE_URL == "sqlite+aiosqlite:///zcore.db" + @pytest.mark.parametrize( "secret_a, secret_b", [ @@ -45,6 +53,7 @@ def test_settings_proxy_resolution(secret_a: str, secret_b: str) -> None: initialize_settings(settings_b) assert secret_b == settings.SECRET_KEY + def test_settings_type_coercion(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("DEBUG", "False") monkeypatch.setenv("POOL_SIZE", "15") @@ -53,6 +62,7 @@ def test_settings_type_coercion(monkeypatch: pytest.MonkeyPatch) -> None: assert fresh_settings.DEBUG is False assert fresh_settings.POOL_SIZE == 15 + def test_settings_case_sensitivity(monkeypatch: pytest.MonkeyPatch) -> None: if sys.platform == "win32": with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: @@ -70,12 +80,14 @@ def test_settings_case_sensitivity(monkeypatch: pytest.MonkeyPatch) -> None: fresh_settings = get_settings() assert fresh_settings.SECRET_KEY == "zcore-insecure-fallback-secret-key-must-be-changed" + def test_settings_extra_ignored(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("RANDOM_ZCORE_ENVIRONMENT_VARIABLE", "ignore-me") container._singletons.clear() fresh_settings = get_settings() assert not hasattr(fresh_settings, "RANDOM_ZCORE_ENVIRONMENT_VARIABLE") + def test_settings_custom_env_file(monkeypatch: pytest.MonkeyPatch) -> None: with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: f.write("SECRET_KEY=env-file-custom-secret-999\nPROJECT_NAME=EnvFileApp") @@ -90,10 +102,13 @@ def test_settings_custom_env_file(monkeypatch: pytest.MonkeyPatch) -> None: finally: os.unlink(temp_filename) + def test_settings_subclass_di_registration() -> None: container._singletons.clear() + class CustomProjectSettings(Settings): CUSTOM_FIELD: str = "custom_value" + custom_inst = CustomProjectSettings() initialize_settings(custom_inst) resolved_parent = container.resolve(Settings) @@ -102,6 +117,7 @@ class CustomProjectSettings(Settings): assert resolved_child is custom_inst assert resolved_parent.CUSTOM_FIELD == "custom_value" + def test_settings_get_settings_fallback() -> None: container._singletons.clear() assert Settings not in container._singletons @@ -109,14 +125,129 @@ def test_settings_get_settings_fallback() -> None: assert Settings in container._singletons assert container.resolve(Settings) is inst + def test_settings_proxy_missing_attribute() -> None: container._singletons.clear() with pytest.raises(AttributeError): _ = settings.NON_EXISTING_DATABASE_PORT + def test_settings_proxy_lazy_evaluation() -> None: container._singletons.clear() assert settings.SECRET_KEY == "zcore-insecure-fallback-secret-key-must-be-changed" new_inst = Settings(SECRET_KEY="lazy-evaluated-new-secret") initialize_settings(new_inst) - assert settings.SECRET_KEY == "lazy-evaluated-new-secret" \ No newline at end of file + assert settings.SECRET_KEY == "lazy-evaluated-new-secret" + + +def test_database_settings_model_defaults_and_custom() -> None: + default_db = DatabaseSettings() + assert default_db.url == "sqlite+aiosqlite:///zcore.db" + assert default_db.pool_size == 5 + assert default_db.max_overflow == 10 + assert default_db.pool_recycle == 1800 + assert default_db.pool_pre_ping is True + assert default_db.echo is False + assert default_db.connect_args == {} + assert default_db.execution_options == {} + assert default_db.extra_engine_kwargs == {} + + custom_db = DatabaseSettings( + url="postgresql+asyncpg://postgres:secret@localhost:5432/zcore_prod", + pool_size=20, + max_overflow=30, + pool_recycle=3600, + pool_pre_ping=False, + echo=True, + connect_args={"server_settings": {"jit": "off"}}, + execution_options={"isolation_level": "REPEATABLE READ"}, + extra_engine_kwargs={"isolation_level": "AUTOCOMMIT"}, + ) + assert custom_db.url == "postgresql+asyncpg://postgres:secret@localhost:5432/zcore_prod" + assert custom_db.pool_size == 20 + assert custom_db.max_overflow == 30 + assert custom_db.pool_recycle == 3600 + assert custom_db.pool_pre_ping is False + assert custom_db.echo is True + assert custom_db.connect_args == {"server_settings": {"jit": "off"}} + assert custom_db.execution_options == {"isolation_level": "REPEATABLE READ"} + assert custom_db.extra_engine_kwargs == {"isolation_level": "AUTOCOMMIT"} + + +def test_logging_settings_model_defaults_and_custom() -> None: + default_log = LoggingSettings() + assert default_log.level == "INFO" + assert default_log.json_format is None + assert default_log.log_sql_queries is True + assert default_log.slow_query_threshold_ms is None + assert default_log.file_path is None + assert default_log.muted_loggers == [ + "uvicorn", + "uvicorn.access", + "uvicorn.error", + "sqlalchemy.engine", + ] + assert default_log.custom_processors == [] + + dummy_processor = lambda logger, method, event_dict: event_dict + custom_log = LoggingSettings( + level="DEBUG", + json_format=True, + log_sql_queries=False, + slow_query_threshold_ms=250.0, + file_path="/var/log/zcore.log", + muted_loggers=["custom.noisy.service"], + custom_processors=[dummy_processor], + ) + assert custom_log.level == "DEBUG" + assert custom_log.json_format is True + assert custom_log.log_sql_queries is False + assert custom_log.slow_query_threshold_ms == 250.0 + assert custom_log.file_path == "/var/log/zcore.log" + assert custom_log.muted_loggers == ["custom.noisy.service"] + assert custom_log.custom_processors == [dummy_processor] + + +def test_settings_sync_validator_database_bidirectional() -> None: + settings_from_flat = Settings( + DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/syncdb", + POOL_SIZE=25, + MAX_OVERFLOW=50, + ) + assert settings_from_flat.DATABASE.url == "postgresql+asyncpg://postgres:postgres@localhost:5432/syncdb" + assert settings_from_flat.DATABASE.pool_size == 25 + assert settings_from_flat.DATABASE.max_overflow == 50 + + settings_from_nested = Settings( + DATABASE=DatabaseSettings( + url="mysql+aiomysql://root:root@localhost:3306/syncdb", + pool_size=12, + max_overflow=24, + ) + ) + assert settings_from_nested.DATABASE_URL == "mysql+aiomysql://root:root@localhost:3306/syncdb" + assert settings_from_nested.POOL_SIZE == 12 + assert settings_from_nested.MAX_OVERFLOW == 24 + + +def test_settings_sync_validator_logging_bidirectional() -> None: + settings_from_flat = Settings(LOG_LEVEL="DEBUG") + assert settings_from_flat.LOGGING.level == "DEBUG" + + settings_from_nested = Settings( + LOGGING=LoggingSettings(level="ERROR") + ) + assert settings_from_nested.LOG_LEVEL == "ERROR" + + +def test_timezone_settings_defaults() -> None: + default_settings = Settings() + assert default_settings.TIMEZONE == "UTC" + assert default_settings.AUTO_CONVERT_TIMEZONE is True + + custom_settings = Settings( + TIMEZONE="Asia/Tehran", + AUTO_CONVERT_TIMEZONE=False, + ) + assert custom_settings.TIMEZONE == "Asia/Tehran" + assert custom_settings.AUTO_CONVERT_TIMEZONE is False \ No newline at end of file From e73ae4b63de34fa8683e197e7893a572ce6fb432 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 16:42:22 +0330 Subject: [PATCH 52/82] fix(config): add bidirectional sync for POOL_SIZE and MAX_OVERFLOW MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added sync from DATABASE.pool_size → POOL_SIZE when POOL_SIZE is default - Added sync from DATABASE.max_overflow → MAX_OVERFLOW when MAX_OVERFLOW is default - Fixed lambda in test_config by replacing with regular function for consistency --- src/zcore/config.py | 6 ++++++ tests/test_config.py | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/zcore/config.py b/src/zcore/config.py index 685997e..cbb45ef 100644 --- a/src/zcore/config.py +++ b/src/zcore/config.py @@ -111,10 +111,16 @@ def _sync_settings(self) -> "Settings": 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 diff --git a/tests/test_config.py b/tests/test_config.py index f83fa28..076db3b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -189,7 +189,8 @@ def test_logging_settings_model_defaults_and_custom() -> None: ] assert default_log.custom_processors == [] - dummy_processor = lambda logger, method, event_dict: event_dict + def dummy_processor(logger, method, event_dict): + return event_dict custom_log = LoggingSettings( level="DEBUG", json_format=True, From f46ddc182e8327ae0a7976f1f98c7c174071e909 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 16:47:42 +0330 Subject: [PATCH 53/82] test(utils): add comprehensive timezone utility tests - Added tests for get_app_timezone valid/cached and invalid fallback with warning logging - Added tests for now() and utc_now() timezone awareness - Added tests for to_app_timezone with naive and aware datetime inputs - Added tests for format_iso_with_app_timezone with UTC and custom timezone - Added test for ZDateTime Pydantic field serialization - Added test for CustomJSONEncoder with AUTO_CONVERT_TIMEZONE toggle --- tests/test_utils.py | 127 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 1 deletion(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index e20a22f..b62d4d9 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,11 +2,22 @@ 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 @@ -244,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 From 013d38076598ea65b091c08c3174db92a2994a6d Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 16:49:59 +0330 Subject: [PATCH 54/82] test(repository): add dialect fallback and model instance acceptance tests - Added test_create_multi_dialect_fallback_without_returning with patch for insert_returning - Added test_delete_multi_dialect_fallback_without_returning with patch for delete_returning - Added test_update_accepts_model_instance_directly with get() not called assertion - Added test_update_multi_accepts_model_instances_as_keys - Added test_delete_accepts_model_instance_directly with get() not called assertion - Added missing blank lines between all test functions --- tests/test_repository.py | 138 +++++++++++++++++++++++++++++++++------ 1 file changed, 119 insertions(+), 19 deletions(-) diff --git a/tests/test_repository.py b/tests/test_repository.py index 2473443..b4a10d6 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -1,7 +1,7 @@ import uuid from collections.abc import AsyncGenerator from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest import pytest_asyncio @@ -28,18 +28,22 @@ def scope_query(cls, query: Any) -> Any: return query.where(cls.name.startswith("Active")) return query + class RepoTestCreateSchema(BaseModel): name: str description: str | None = None + class RepoTestUpdateSchema(BaseModel): name: str | None = None description: str | None = None + class RepoTestRepository(BaseRepository[RepoTestModel]): def __init__(self, db: Any) -> None: super().__init__(RepoTestModel, db) + @pytest_asyncio.fixture(autouse=True) async def setup_test_tables(test_engine: Any) -> AsyncGenerator[None, None]: async with test_engine.begin() as conn: @@ -48,6 +52,7 @@ async def setup_test_tables(test_engine: Any) -> AsyncGenerator[None, None]: async with test_engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) + @pytest.mark.anyio @pytest.mark.parametrize( "name, description", @@ -59,7 +64,7 @@ async def setup_test_tables(test_engine: Any) -> AsyncGenerator[None, None]: async def test_repo_create_and_get(db_session: Any, name: str, description: str | None) -> None: repo = RepoTestRepository(db_session) schema = RepoTestCreateSchema(name=name, description=description) - + created = await repo.create(schema) assert created.id is not None assert created.name == name @@ -69,6 +74,7 @@ async def test_repo_create_and_get(db_session: Any, name: str, description: str assert fetched_with_fields is not None assert fetched_with_fields.name == name + @pytest.mark.anyio @pytest.mark.parametrize( "schemas, expect_db_hit", @@ -89,7 +95,7 @@ async def test_repo_create_multi_empty_and_filled( expect_db_hit: bool ) -> None: repo = RepoTestRepository(db_session) - + spy_session = AsyncMock(wraps=db_session) spy_repo = RepoTestRepository(spy_session) @@ -104,6 +110,7 @@ async def test_repo_create_multi_empty_and_filled( for i, item in enumerate(results): assert item.name == schemas[i].name + @pytest.mark.anyio @pytest.mark.parametrize( "partial, expected_desc", @@ -123,6 +130,7 @@ async def test_repo_partial_update(db_session: Any, partial: bool, expected_desc assert updated.name == "Updated Name" assert updated.description == expected_desc + @pytest.mark.anyio @pytest.mark.parametrize( "non_existent_id", @@ -132,7 +140,7 @@ async def test_repo_partial_update(db_session: Any, partial: bool, expected_desc ) async def test_repo_delete_multi(db_session: Any, non_existent_id: int) -> None: repo = RepoTestRepository(db_session) - + item1 = await repo.create(RepoTestCreateSchema(name="Item 1")) item2 = await repo.create(RepoTestCreateSchema(name="Item 2")) @@ -147,6 +155,7 @@ async def test_repo_delete_multi(db_session: Any, non_existent_id: int) -> None: assert await repo.get(id=item1.id) is None assert await repo.get(id=item2.id) is None + @pytest.mark.anyio async def test_repo_exist(db_session: Any) -> None: repo = RepoTestRepository(db_session) @@ -155,6 +164,7 @@ async def test_repo_exist(db_session: Any) -> None: assert await repo.exist(RepoTestModel.name == "UniqueItem") is True assert await repo.exist(name="NonExistent") is False + @pytest.mark.anyio async def test_repo_count(db_session: Any) -> None: repo = RepoTestRepository(db_session) @@ -163,17 +173,19 @@ async def test_repo_count(db_session: Any) -> None: assert await repo.count() == 2 assert await repo.count(RepoTestModel.name == "ItemA") == 1 + @pytest.mark.anyio async def test_repo_get_by_ids_empty_and_options(db_session: Any) -> None: repo = RepoTestRepository(db_session) res = await repo.get_by_ids([]) assert res == [] - + item = await repo.create(RepoTestCreateSchema(name="Test")) res2 = await repo.get_by_ids([item.id], fields=[RepoTestModel.name]) assert len(res2) == 1 assert res2[0].name == "Test" + @pytest.mark.anyio async def test_repo_update_multi(db_session: Any) -> None: repo = RepoTestRepository(db_session) @@ -195,18 +207,20 @@ async def test_repo_update_multi(db_session: Any) -> None: assert fetched2.name == "B_New" assert fetched2.description == "D2_New" + @pytest.mark.anyio async def test_repo_delete_single(db_session: Any) -> None: repo = RepoTestRepository(db_session) item = await repo.create(RepoTestCreateSchema(name="DelMe")) - + deleted = await repo.delete(item.id) assert deleted is not None assert deleted.name == "DelMe" - + assert await repo.get(id=item.id) is None assert await repo.delete(99999) is None + @pytest.mark.anyio async def test_repo_create_multi_no_refresh(db_session: Any) -> None: repo = RepoTestRepository(db_session) @@ -218,15 +232,16 @@ async def test_repo_create_multi_no_refresh(db_session: Any) -> None: assert len(results) == 2 assert results[0].name == "NR1" + @pytest.mark.anyio async def test_repo_page_number_pagination(db_session: Any) -> None: repo = RepoTestRepository(db_session) for i in range(15): await repo.create(RepoTestCreateSchema(name=f"Item_{i:02d}")) - + params = PageNumberParams(page=2, size=5, sort_by="name", sort_order="desc") res = await repo.get_list(pagination=params) - + assert res.meta["total"] == 15 assert res.meta["page"] == 2 assert res.meta["total_pages"] == 3 @@ -235,6 +250,7 @@ async def test_repo_page_number_pagination(db_session: Any) -> None: assert len(res.data) == 5 assert res.data[0].name == "Item_09" + @pytest.mark.anyio async def test_repo_page_number_sorting_validation(db_session: Any) -> None: repo = RepoTestRepository(db_session) @@ -242,6 +258,7 @@ async def test_repo_page_number_sorting_validation(db_session: Any) -> None: with pytest.raises(ValidationError): await repo.get_list(pagination=params) + @pytest.mark.anyio async def test_repo_cursor_pagination(db_session: Any) -> None: repo = RepoTestRepository(db_session) @@ -249,24 +266,25 @@ async def test_repo_cursor_pagination(db_session: Any) -> None: for i in range(5): item = await repo.create(RepoTestCreateSchema(name=f"C_{i}")) items.append(item) - + params1 = CursorParams(size=2) res1 = await repo.get_list(pagination=params1) assert len(res1.data) == 2 assert res1.meta["has_more"] is True assert res1.meta["next_cursor"] is not None - + params2 = CursorParams(size=2, cursor=res1.meta["next_cursor"]) res2 = await repo.get_list(pagination=params2) assert len(res2.data) == 2 assert res2.meta["has_more"] is True - + params3 = CursorParams(size=2, cursor=res2.meta["next_cursor"]) res3 = await repo.get_list(pagination=params3) assert len(res3.data) == 1 assert res3.meta["has_more"] is False assert res3.meta["next_cursor"] is None + @pytest.mark.anyio async def test_repo_cursor_invalid(db_session: Any) -> None: repo = RepoTestRepository(db_session) @@ -274,13 +292,14 @@ async def test_repo_cursor_invalid(db_session: Any) -> None: with pytest.raises(ValidationError): await repo.get_list(pagination=params) + @pytest.mark.anyio async def test_repo_search_engine_complex(db_session: Any) -> None: repo = RepoTestRepository(db_session) await repo.create(RepoTestCreateSchema(name="Apple", description="Fruit")) await repo.create(RepoTestCreateSchema(name="Banana", description="Fruit")) await repo.create(RepoTestCreateSchema(name="Carrot", description="Vegetable")) - + req = SearchRequest( filters=[ FilterItem( @@ -296,38 +315,40 @@ async def test_repo_search_engine_complex(db_session: Any) -> None: assert len(res) == 1 assert res[0].name == "Banana" + @pytest.mark.anyio async def test_repo_search_security_violation(db_session: Any) -> None: repo = RepoTestRepository(db_session) - + token = ctx.initialize() table_suffix = RepoTestModel.__tablename__.split('_')[-1] ctx.restricted_fields = frozenset([ "description", f"repo_test_model_{table_suffix}.description" ]) - + req = SearchRequest( filters=[ FilterItem(field="description", op="eq", value="Fruit") ] ) - + try: with pytest.raises(ForbiddenError): await repo.search(req) finally: ctx.reset(token) + @pytest.mark.anyio async def test_repo_scoped_row_level_isolation(db_session: Any) -> None: repo = RepoTestRepository(db_session) await repo.create(RepoTestCreateSchema(name="Active_1")) await repo.create(RepoTestCreateSchema(name="Active_2")) await repo.create(RepoTestCreateSchema(name="Inactive_1")) - + assert await repo.count() == 3 - + RepoTestModel._scoped_mode = True try: assert await repo.count() == 2 @@ -335,4 +356,83 @@ async def test_repo_scoped_row_level_isolation(db_session: Any) -> None: for item in res: assert item.name.startswith("Active") finally: - RepoTestModel._scoped_mode = False \ No newline at end of file + RepoTestModel._scoped_mode = False + + +@pytest.mark.anyio +async def test_create_multi_dialect_fallback_without_returning(db_session: Any) -> None: + repo = RepoTestRepository(db_session) + schemas = [ + RepoTestCreateSchema(name="Fallback 1", description="FB 1"), + RepoTestCreateSchema(name="Fallback 2", description="FB 2"), + ] + with patch.object(db_session.bind.dialect, "insert_returning", False): + results = await repo.create_multi(schemas, refresh=True) + assert len(results) == 2 + assert results[0].name == "Fallback 1" + assert results[1].name == "Fallback 2" + assert results[0].id is not None + assert results[1].id is not None + + +@pytest.mark.anyio +async def test_delete_multi_dialect_fallback_without_returning(db_session: Any) -> None: + repo = RepoTestRepository(db_session) + item1 = await repo.create(RepoTestCreateSchema(name="DelFB 1")) + item2 = await repo.create(RepoTestCreateSchema(name="DelFB 2")) + with patch.object(db_session.bind.dialect, "delete_returning", False): + deleted = await repo.delete_multi([item1.id, item2.id, 999999]) + assert len(deleted) == 2 + deleted_ids = {d.id for d in deleted} + assert item1.id in deleted_ids + assert item2.id in deleted_ids + assert await repo.get(id=item1.id) is None + assert await repo.get(id=item2.id) is None + + +@pytest.mark.anyio +async def test_update_accepts_model_instance_directly(db_session: Any) -> None: + repo = RepoTestRepository(db_session) + created = await repo.create(RepoTestCreateSchema(name="Original", description="Original Desc")) + with patch.object(repo, "get", AsyncMock()) as mock_get: + updated = await repo.update( + target=created, + schema=RepoTestUpdateSchema(name="Updated Direct", description="Updated Desc"), + ) + mock_get.assert_not_called() + assert updated is not None + assert updated.id == created.id + assert updated.name == "Updated Direct" + assert updated.description == "Updated Desc" + + +@pytest.mark.anyio +async def test_update_multi_accepts_model_instances_as_keys(db_session: Any) -> None: + repo = RepoTestRepository(db_session) + item1 = await repo.create(RepoTestCreateSchema(name="Item1", description="D1")) + item2 = await repo.create(RepoTestCreateSchema(name="Item2", description="D2")) + + data = { + item1: RepoTestUpdateSchema(name="Item1_Upd", description="D1_Upd"), + item2: RepoTestUpdateSchema(name="Item2_Upd", description="D2_Upd"), + } + updated = await repo.update_multi(data) + assert len(updated) == 2 + fetched1 = await repo.get(id=item1.id) + fetched2 = await repo.get(id=item2.id) + assert fetched1.name == "Item1_Upd" + assert fetched1.description == "D1_Upd" + assert fetched2.name == "Item2_Upd" + assert fetched2.description == "D2_Upd" + + +@pytest.mark.anyio +async def test_delete_accepts_model_instance_directly(db_session: Any) -> None: + repo = RepoTestRepository(db_session) + item = await repo.create(RepoTestCreateSchema(name="Delete Direct")) + with patch.object(repo, "get", AsyncMock()) as mock_get: + deleted = await repo.delete(target=item) + mock_get.assert_not_called() + assert deleted is not None + assert deleted.id == item.id + assert await repo.get(id=item.id) is None \ No newline at end of file From 0c0040e4873e9f9dd1c5fe32d52580f584fc3bc8 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 16:52:29 +0330 Subject: [PATCH 55/82] test(db): add comprehensive database manager and JSON serializer tests - Added test for DatabaseManager init with DatabaseSettings config object - Added test for missing URL ValueError - Added test for SQL query logger suppression when log_sql_queries is False - Added test for slow_query_threshold filter (fast query skipped, slow query logged) - Added test for custom JSON serializer support with UUID, Decimal, datetime values in JSON column - Added missing blank lines between all test functions --- tests/test_db_lifecycle.py | 214 ++++++++++++++++++++++++++++++------- 1 file changed, 178 insertions(+), 36 deletions(-) diff --git a/tests/test_db_lifecycle.py b/tests/test_db_lifecycle.py index f54c481..edadfb2 100644 --- a/tests/test_db_lifecycle.py +++ b/tests/test_db_lifecycle.py @@ -1,12 +1,16 @@ +import datetime import uuid from collections.abc import AsyncGenerator from contextlib import asynccontextmanager +from decimal import Decimal from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest +from sqlalchemy import JSON, Column, Integer, select -from zcore.db.setup import DatabaseManager +from zcore.config import DatabaseSettings, settings +from zcore.db.setup import Base, DatabaseManager from zcore.db.uow import UnitOfWork @@ -26,12 +30,13 @@ async def test_db_manager_init(db_url: str, pool_size: int, max_overflow: int) - max_overflow=max_overflow, echo=False ) - + assert manager._engine is not None assert manager._session_factory is not None - + await manager.close() + @pytest.mark.parametrize( "exception_class", [ @@ -44,7 +49,7 @@ async def test_db_manager_init(db_url: str, pool_size: int, max_overflow: int) - async def test_db_session_rollback_on_error(exception_class: type[Exception]) -> None: manager = DatabaseManager() mock_session = AsyncMock() - + @asynccontextmanager async def mock_session_ctx() -> AsyncGenerator[AsyncMock, None]: yield mock_session @@ -58,6 +63,7 @@ async def mock_session_ctx() -> AsyncGenerator[AsyncMock, None]: mock_session.rollback.assert_called_once() + @pytest.mark.parametrize( "events_to_register", [ @@ -74,12 +80,12 @@ async def test_uow_commit_emits_events(events_to_register: list[tuple[str, dict[ session = AsyncMock() dispatcher = AsyncMock() uow = UnitOfWork(session, dispatcher) - + call_order: list[str] = [] - + async def track_commit() -> None: call_order.append("commit") - + async def track_dispatch(event: str, payload: Any) -> list[Any]: call_order.append(f"dispatch:{event}") return [] @@ -99,6 +105,7 @@ async def track_dispatch(event: str, payload: Any) -> list[Any]: else: assert call_order == ["commit"] + @pytest.mark.parametrize( "exception_class, events_to_register", [ @@ -134,6 +141,7 @@ async def test_uow_rollback_clears_events( dispatcher.dispatch.assert_not_called() session.rollback.assert_called() + @pytest.mark.anyio async def test_db_manager_uninitialized_access() -> None: manager = DatabaseManager() @@ -141,10 +149,11 @@ async def test_db_manager_uninitialized_access() -> None: async with manager.session(): pass + @pytest.mark.anyio async def test_db_manager_sql_logger_timing() -> None: listeners = {} - + def mock_listens_for(target, identifier): def decorator(fn): listeners[identifier] = fn @@ -153,35 +162,36 @@ def decorator(fn): with patch("sqlalchemy.event.listens_for", mock_listens_for), \ patch("structlog.get_logger") as mock_get_logger: - + mock_logger = MagicMock() mock_get_logger.return_value = mock_logger - + manager = DatabaseManager() sync_engine_mock = MagicMock() sync_engine_mock.dialect.name = "postgresql" - + manager._register_query_logger(sync_engine_mock) - + assert "before_cursor_execute" in listeners assert "after_cursor_execute" in listeners - + mock_context = MagicMock() listeners["before_cursor_execute"](None, None, "SELECT 1", {}, mock_context, False) assert hasattr(mock_context, "_query_start_time") - + listeners["after_cursor_execute"](None, None, "SELECT 1", {}, mock_context, False) mock_logger.info.assert_called_once() - + mock_logger.reset_mock() listeners["after_cursor_execute"](None, None, "SELECT * FROM pg_catalog.pg_tables", {}, mock_context, False) mock_logger.info.assert_not_called() + @pytest.mark.anyio async def test_db_manager_session_propagates_exception() -> None: manager = DatabaseManager() mock_session = AsyncMock() - + @asynccontextmanager async def mock_session_ctx() -> AsyncGenerator[AsyncMock, None]: yield mock_session @@ -191,91 +201,223 @@ async def mock_session_ctx() -> AsyncGenerator[AsyncMock, None]: with pytest.raises(ValueError, match="Internal Error"): async with manager.session(): raise ValueError("Internal Error") - + mock_session.rollback.assert_called_once() + @pytest.mark.anyio async def test_uow_event_handler_failure_gracefulness() -> None: session = AsyncMock() dispatcher = AsyncMock() uow = UnitOfWork(session, dispatcher) - + uow.register_event("e1", {}) uow.register_event("e2", {}) - + call_order = [] - + async def mock_dispatch(event: str, payload: Any) -> list[Any]: call_order.append(event) if event == "e1": raise ValueError("E1 handler failed") return [] - + dispatcher.dispatch.side_effect = mock_dispatch - + await uow.commit() - + assert call_order == ["e1", "e2"] assert len(uow._pending_events) == 0 + @pytest.mark.anyio async def test_uow_db_commit_failure() -> None: session = AsyncMock() dispatcher = AsyncMock() uow = UnitOfWork(session, dispatcher) - + session.commit.side_effect = Exception("DB error") uow.register_event("e1", {}) - + with pytest.raises(Exception, match="DB error"): await uow.commit() - + session.rollback.assert_called_once() dispatcher.dispatch.assert_not_called() assert len(uow._pending_events) == 1 + @pytest.mark.anyio async def test_uow_managed_flag() -> None: session = AsyncMock() session.info = {} dispatcher = AsyncMock() uow = UnitOfWork(session, dispatcher) - + assert session.info.get("uow_managed") is not True - + async with uow: assert session.info.get("uow_managed") is True - + assert session.info.get("uow_managed") is False + @pytest.mark.anyio async def test_uow_context_manager_auto_commit() -> None: session = AsyncMock() session.info = {} dispatcher = AsyncMock() uow = UnitOfWork(session, dispatcher) - + uow.register_event("e1", {}) - + async with uow: pass - + session.commit.assert_called_once() dispatcher.dispatch.assert_called_once_with("e1", {}) + @pytest.mark.anyio async def test_uow_context_manager_auto_rollback() -> None: session = AsyncMock() session.info = {} dispatcher = AsyncMock() uow = UnitOfWork(session, dispatcher) - + uow.register_event("e1", {}) - + with pytest.raises(ValueError, match="Err"): async with uow: raise ValueError("Err") - + session.rollback.assert_called_once() dispatcher.dispatch.assert_not_called() - assert len(uow._pending_events) == 0 \ No newline at end of file + assert len(uow._pending_events) == 0 + + +@pytest.mark.anyio +async def test_database_manager_init_with_database_settings_object() -> None: + config = DatabaseSettings( + url="sqlite+aiosqlite:///:memory:", + pool_size=10, + max_overflow=20, + pool_recycle=3600, + pool_pre_ping=True, + connect_args={"timeout": 30}, + execution_options={"isolation_level": "AUTOCOMMIT"}, + ) + manager = DatabaseManager() + manager.init_app(config=config) + assert manager._engine is not None + assert manager._session_factory is not None + await manager.close() + + +def test_database_manager_init_missing_url_raises_value_error() -> None: + manager = DatabaseManager() + with pytest.raises(ValueError, match="A valid database URL must be provided"): + manager.init_app(db_url=None, config={"url": None}) + + +def test_sql_query_logger_suppression_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + listeners = {} + + def mock_listens_for(target: Any, identifier: str) -> Any: + def decorator(fn: Any) -> Any: + listeners[identifier] = fn + return fn + return decorator + + with patch("sqlalchemy.event.listens_for", mock_listens_for), \ + patch("structlog.get_logger") as mock_get_logger: + mock_logger = MagicMock() + mock_get_logger.return_value = mock_logger + + manager = DatabaseManager() + sync_engine_mock = MagicMock() + sync_engine_mock.dialect.name = "postgresql" + manager._register_query_logger(sync_engine_mock) + + mock_context = MagicMock() + listeners["before_cursor_execute"](None, None, "SELECT 1", {}, mock_context, False) + + monkeypatch.setattr(settings.LOGGING, "log_sql_queries", False) + listeners["after_cursor_execute"](None, None, "SELECT 1", {}, mock_context, False) + mock_logger.info.assert_not_called() + + +def test_sql_query_logger_slow_query_threshold(monkeypatch: pytest.MonkeyPatch) -> None: + listeners = {} + + def mock_listens_for(target: Any, identifier: str) -> Any: + def decorator(fn: Any) -> Any: + listeners[identifier] = fn + return fn + return decorator + + with patch("sqlalchemy.event.listens_for", mock_listens_for), \ + patch("structlog.get_logger") as mock_get_logger: + mock_logger = MagicMock() + mock_get_logger.return_value = mock_logger + + manager = DatabaseManager() + sync_engine_mock = MagicMock() + sync_engine_mock.dialect.name = "postgresql" + manager._register_query_logger(sync_engine_mock) + + monkeypatch.setattr(settings.LOGGING, "log_sql_queries", True) + monkeypatch.setattr(settings.LOGGING, "slow_query_threshold_ms", 100.0) + + fast_context = MagicMock() + fast_context._query_start_time = 1000.0 + with patch("time.perf_counter", return_value=1000.01): + listeners["after_cursor_execute"](None, None, "SELECT 1", {}, fast_context, False) + mock_logger.info.assert_not_called() + + slow_context = MagicMock() + slow_context._query_start_time = 1000.0 + with patch("time.perf_counter", return_value=1000.20): + listeners["after_cursor_execute"](None, None, "SELECT 1", {}, slow_context, False) + mock_logger.info.assert_called_once() + + +@pytest.mark.anyio +async def test_database_custom_json_serializer_support() -> None: + table_name = f"json_test_{uuid.uuid4().hex[:6]}" + + class JsonTestModel(Base): + __tablename__ = table_name + id = Column(Integer, primary_key=True, autoincrement=True) + payload = Column(JSON, nullable=False) + + manager = DatabaseManager() + manager.init_app(db_url="sqlite+aiosqlite:///:memory:") + + async with manager._engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + test_uid = uuid.UUID("12345678-1234-5678-1234-567812345678") + test_decimal = Decimal("99.95") + test_dt = datetime.datetime(2026, 7, 2, 10, 0, 0) + + async with manager.session() as session: + record = JsonTestModel( + payload={ + "uid": test_uid, + "decimal": test_decimal, + "dt": test_dt, + } + ) + session.add(record) + await session.commit() + record_id = record.id + + async with manager.session() as session: + result = await session.execute(select(JsonTestModel).where(JsonTestModel.id == record_id)) + fetched = result.scalar_one() + assert fetched.payload["uid"] == str(test_uid) + assert fetched.payload["decimal"] == str(test_decimal) + assert "2026-07-02" in fetched.payload["dt"] + + await manager.close() \ No newline at end of file From 1912c1ca911a41fefd97e232479e7b8f1dfc4de6 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 17:05:54 +0330 Subject: [PATCH 56/82] feat(search): unify contains operator into ilike family and add tests - Consolidated contains operator handling into ilike family block - Removed duplicate contains logic with SQLAlchemy contains fallback - Added tests for set and iterable type coercion in search operations - Added tests for text operators (ilike, startswith, endswith, contains) on non-string columns with cast - Added test for column without python_type fallback - Added test for numeric coercion from string with validation error --- src/zcore/db/search.py | 26 ++------------ tests/test_search.py | 80 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 74 insertions(+), 32 deletions(-) diff --git a/src/zcore/db/search.py b/src/zcore/db/search.py index b220308..efce6b2 100644 --- a/src/zcore/db/search.py +++ b/src/zcore/db/search.py @@ -434,7 +434,7 @@ def _compare_column(self, col: Any, op: str, value: Any) -> Any: if op == "is_null": return col.is_(None) if value else col.isnot(None) - if op in ("ilike", "startswith", "endswith"): + if op in ("ilike", "startswith", "endswith", "contains"): escaped_value = self._escape_like_wildcards( str(value) if value is not None else "" ) @@ -447,35 +447,13 @@ def _compare_column(self, col: Any, op: str, value: Any) -> Any: target_col = col if is_str else cast(col, String) - if op == "ilike": + 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 == "contains": - try: - is_str = ( - hasattr(col.type, "python_type") and col.type.python_type is str - ) - except (NotImplementedError, AttributeError): - is_str = False - - if is_str: - escaped_value = self._escape_like_wildcards( - str(value) if value is not None else "" - ) - return col.ilike(f"%{escaped_value}%", escape="\\") - - try: - return col.contains(value) - except Exception: - escaped_value = self._escape_like_wildcards( - str(value) if value is not None else "" - ) - return cast(col, String).ilike(f"%{escaped_value}%", escape="\\") - if op == "between": if not isinstance(value, (list, tuple)) or len(value) != 2: col_name = getattr(col, "key", str(col)) diff --git a/tests/test_search.py b/tests/test_search.py index c997311..5b81576 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -340,15 +340,15 @@ def test_search_include_depth_and_relation( @pytest.mark.anyio async def test_search_lt_le_ge(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchUser) - + req_lt = SearchRequest(filters=[FilterItem(field="id", op="lt", value=2)]) res_lt = await db_session.execute(engine.build_base_query(req_lt)) assert {u.id for u in res_lt.scalars().all()} == {1} - + req_le = SearchRequest(filters=[FilterItem(field="id", op="le", value=2)]) res_le = await db_session.execute(engine.build_base_query(req_le)) assert {u.id for u in res_le.scalars().all()} == {1, 2} - + req_ge = SearchRequest(filters=[FilterItem(field="id", op="ge", value=2)]) res_ge = await db_session.execute(engine.build_base_query(req_ge)) assert {u.id for u in res_ge.scalars().all()} == {2, 3} @@ -365,11 +365,11 @@ async def test_search_in_operator(db_session: Any, seed_data: None) -> None: @pytest.mark.anyio async def test_search_is_null_operator(db_session: Any, seed_data: None) -> None: engine = SearchEngine(SearchProfile) - + req_null = SearchRequest(filters=[FilterItem(field="bio", op="is_null", value=True)]) res_null = await db_session.execute(engine.build_base_query(req_null)) assert {p.id for p in res_null.scalars().all()} == {2} - + req_not_null = SearchRequest(filters=[FilterItem(field="bio", op="is_null", value=False)]) res_not_null = await db_session.execute(engine.build_base_query(req_not_null)) assert {p.id for p in res_not_null.scalars().all()} == {1, 3} @@ -389,11 +389,11 @@ async def test_search_wildcard_escaping(db_session: Any, seed_data: None) -> Non await db_session.flush() engine = SearchEngine(SearchUser) - + req_percent = SearchRequest(filters=[FilterItem(field="username", op="ilike", value="admin%")]) res_percent = await db_session.execute(engine.build_base_query(req_percent)) assert {u.id for u in res_percent.scalars().all()} == {1} - + req_underscore = SearchRequest(filters=[FilterItem(field="username", op="ilike", value="user_special")]) res_underscore = await db_session.execute(engine.build_base_query(req_underscore)) results = list(res_underscore.scalars().all()) @@ -515,4 +515,68 @@ async def test_search_complex_combined_logical_filters(db_session: Any, seed_dat ) req = SearchRequest(filters=[filter_item]) res = await db_session.execute(engine.build_base_query(req)) - assert {u.id for u in res.scalars().all()} == {1, 3} \ No newline at end of file + assert {u.id for u in res.scalars().all()} == {1, 3} + + +@pytest.mark.anyio +async def test_search_type_coercion_set_and_iterables(db_session: Any, seed_data: None) -> None: + engine = SearchEngine(SearchUser) + req_set = SearchRequest(filters=[FilterItem(field="id", op="in", value={1, 2})]) + res_set = await db_session.execute(engine.build_base_query(req_set)) + assert {u.id for u in res_set.scalars().all()} == {1, 2} + + req_set_str = SearchRequest(filters=[FilterItem(field="id", op="in", value={"1", "3"})]) + res_set_str = await db_session.execute(engine.build_base_query(req_set_str)) + assert {u.id for u in res_set_str.scalars().all()} == {1, 3} + + +@pytest.mark.anyio +async def test_search_text_operators_on_non_string_columns_cast(db_session: Any, seed_data: None) -> None: + engine = SearchEngine(SearchUser) + + req_ilike = SearchRequest(filters=[FilterItem(field="id", op="ilike", value="1")]) + res_ilike = await db_session.execute(engine.build_base_query(req_ilike)) + assert {u.id for u in res_ilike.scalars().all()} == {1} + + req_startswith = SearchRequest(filters=[FilterItem(field="uid", op="startswith", value="22222222")]) + res_startswith = await db_session.execute(engine.build_base_query(req_startswith)) + assert {u.id for u in res_startswith.scalars().all()} == {2} + + req_endswith = SearchRequest(filters=[FilterItem(field="uid", op="endswith", value="333333333333")]) + res_endswith = await db_session.execute(engine.build_base_query(req_endswith)) + assert {u.id for u in res_endswith.scalars().all()} == {3} + + req_contains = SearchRequest(filters=[FilterItem(field="uid", op="contains", value="11111111")]) + res_contains = await db_session.execute(engine.build_base_query(req_contains)) + assert {u.id for u in res_contains.scalars().all()} == {1} + + +def test_search_column_without_python_type_fallback() -> None: + engine = SearchEngine(SearchUser) + + class CustomNoPythonType: + @property + def python_type(self) -> Any: + raise NotImplementedError + + class MockColumn: + key = "unsupported_col" + type = CustomNoPythonType() + + mock_col = MockColumn() + result = engine._coerce_value(mock_col, "raw_uncoerced_value") + assert result == "raw_uncoerced_value" + + +@pytest.mark.anyio +async def test_search_numeric_coercion_from_string(db_session: Any, seed_data: None) -> None: + engine = SearchEngine(SearchUser) + + req_valid = SearchRequest(filters=[FilterItem(field="id", op="eq", value="2")]) + res_valid = await db_session.execute(engine.build_base_query(req_valid)) + assert {u.id for u in res_valid.scalars().all()} == {2} + + with pytest.raises(ValidationError) as exc_info: + req_invalid = SearchRequest(filters=[FilterItem(field="id", op="eq", value="not_a_number")]) + engine.build_base_query(req_invalid) + assert "failed to coerce" in str(exc_info.value).lower() \ No newline at end of file From 8c613784a411599aad928b69e6b481a63d0438fe Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 17:11:58 +0330 Subject: [PATCH 57/82] test(web): add router PK type detection, Zchema timezone serialization, and request logging tests - Added test for BaseRouter PK type auto-detection for int and UUID columns - Added test for explicit pk_type override - Added test for weighted route specificity sorting - Added test for Zchema recursive timezone serialization with nested lists - Added test for Zchema input validation preserving raw datetime types - Added test for RequestLogMiddleware capturing status code and client IP - Added missing blank lines and type annotations throughout --- tests/test_web.py | 223 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 212 insertions(+), 11 deletions(-) diff --git a/tests/test_web.py b/tests/test_web.py index e7b3f11..21e6bed 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,150 @@ 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: + 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: datetime.datetime + + class OrderSummary(Zchema): + __model__ = "order" + order_id: str + created_at: datetime.datetime + 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() + 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 From caddf1a1b6e055c6f5845e2684f0d52c49191fe7 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 17:16:39 +0330 Subject: [PATCH 58/82] test(logging): add comprehensive tests for logging configuration features - Added test for setup_logging with LoggingSettings object (level + json_format) - Added test for setup_logging with dict config override - Added test for RotatingFileHandler integration with file_path setting - Added test for extra_handlers and custom_processors injection - Fixed whitespace formatting throughout file --- tests/test_logging.py | 128 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 106 insertions(+), 22 deletions(-) diff --git a/tests/test_logging.py b/tests/test_logging.py index e3cb0da..965d7ca 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -7,7 +7,7 @@ import pytest import structlog -from zcore.config import settings +from zcore.config import LoggingSettings, settings @pytest.mark.parametrize( @@ -40,18 +40,18 @@ def test_logging_format_by_environment( def test_suppress_third_party_duplicate_handlers(monkeypatch: pytest.MonkeyPatch) -> None: loggers = ["uvicorn", "uvicorn.access", "sqlalchemy.engine"] dummy_handlers = {name: [logging.NullHandler()] for name in loggers} - + for name in loggers: logger = logging.getLogger(name) logger.handlers = list(dummy_handlers[name]) logger.propagate = False - + import zcore.logging.config as logging_config importlib.reload(logging_config) - + with patch("structlog.configure"): logging_config.setup_logging() - + for name in loggers: logger = logging.getLogger(name) assert len(logger.handlers) == 0 @@ -61,12 +61,12 @@ def test_suppress_third_party_duplicate_handlers(monkeypatch: pytest.MonkeyPatch @pytest.mark.parametrize("rich_installed", [True, False]) def test_rich_integration_debug_mode(monkeypatch: pytest.MonkeyPatch, rich_installed: bool) -> None: monkeypatch.setattr(settings, "DEBUG", True) - + import zcore.logging.config as logging_config importlib.reload(logging_config) - + mock_install = MagicMock() - + def mock_import(name: str, *args: Any, **kwargs: Any) -> Any: if name == "rich.traceback": if rich_installed: @@ -75,14 +75,14 @@ def mock_import(name: str, *args: Any, **kwargs: Any) -> Any: return mock_module raise ImportError() return original_import(name, *args, **kwargs) - + import builtins original_import = builtins.__import__ monkeypatch.setattr(builtins, "__import__", mock_import) - + with patch("structlog.configure"): logging_config.setup_logging() - + if rich_installed: mock_install.assert_called_once_with(show_locals=False) else: @@ -91,18 +91,18 @@ def mock_import(name: str, *args: Any, **kwargs: Any) -> Any: def test_shared_processors_chain_integrity(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(settings, "DEBUG", False) - + import zcore.logging.config as logging_config importlib.reload(logging_config) - + with patch("structlog.configure") as mock_configure: logging_config.setup_logging() configured_processors = mock_configure.call_args[1].get("processors", []) - + assert structlog.contextvars.merge_contextvars in configured_processors assert structlog.stdlib.add_log_level in configured_processors assert structlog.processors.format_exc_info in configured_processors - + assert any(isinstance(p, structlog.stdlib.PositionalArgumentsFormatter) for p in configured_processors) assert any(isinstance(p, structlog.processors.TimeStamper) for p in configured_processors) @@ -121,10 +121,10 @@ def test_dynamic_log_level_configuration( expected_level: int ) -> None: monkeypatch.setattr(settings, "LOG_LEVEL", log_level_setting) - + import zcore.logging.config as logging_config importlib.reload(logging_config) - + with patch("structlog.configure"): logging_config.setup_logging(log_level=log_level_setting) assert logging.getLogger().level == expected_level @@ -156,20 +156,104 @@ def emit(self, record: logging.LogRecord) -> None: def test_contextvars_binding_verification() -> None: structlog.contextvars.clear_contextvars() structlog.contextvars.bind_contextvars(request_id="test_req_xyz") - + event_dict = structlog.contextvars.merge_contextvars(None, None, {"event": "hello"}) assert event_dict.get("request_id") == "test_req_xyz" - + structlog.contextvars.clear_contextvars() def test_factory_and_caching_configurations(monkeypatch: pytest.MonkeyPatch) -> None: import zcore.logging.config as logging_config importlib.reload(logging_config) - + with patch("structlog.configure") as mock_configure: logging_config.setup_logging() - + kwargs = mock_configure.call_args[1] assert isinstance(kwargs.get("logger_factory"), structlog.stdlib.LoggerFactory) - assert kwargs.get("cache_logger_on_first_use") is True \ No newline at end of file + assert kwargs.get("cache_logger_on_first_use") is True + + +def test_setup_logging_with_logging_settings_object() -> None: + custom_cfg = LoggingSettings(level="DEBUG", json_format=True) + import zcore.logging.config as logging_config + importlib.reload(logging_config) + + with patch("structlog.configure"): + logging_config.setup_logging(config=custom_cfg) + root_logger = logging.getLogger() + assert root_logger.level == logging.DEBUG + formatter = root_logger.handlers[0].formatter + assert isinstance(formatter.processors[-1], structlog.processors.JSONRenderer) + + dict_cfg = {"level": "WARNING", "json_format": False} + with patch("structlog.configure"): + logging_config.setup_logging(config=dict_cfg) + root_logger = logging.getLogger() + assert root_logger.level == logging.WARNING + + +def test_setup_logging_rotating_file_handler(tmp_path: Any) -> None: + from logging.handlers import RotatingFileHandler + + log_file = tmp_path / "test_app.log" + cfg = LoggingSettings(file_path=str(log_file)) + + import zcore.logging.config as logging_config + importlib.reload(logging_config) + + with patch("structlog.configure"): + logging_config.setup_logging(config=cfg) + root_logger = logging.getLogger() + file_handlers = [h for h in root_logger.handlers if isinstance(h, RotatingFileHandler)] + assert len(file_handlers) == 1 + fh = file_handlers[0] + assert fh.maxBytes == 10 * 1024 * 1024 + assert fh.backupCount == 5 + assert fh.baseFilename == str(log_file) + + +def test_setup_logging_dict_config_override() -> None: + dict_cfg = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "level": "INFO", + } + }, + "root": { + "handlers": ["console"], + "level": "INFO", + }, + } + import zcore.logging.config as logging_config + importlib.reload(logging_config) + + with patch("logging.config.dictConfig") as mock_dict_config: + logging_config.setup_logging(dict_config=dict_cfg) + mock_dict_config.assert_called_once_with(dict_cfg) + + +def test_setup_logging_extra_handlers_and_custom_processors() -> None: + dummy_handler = logging.NullHandler() + def dummy_proc(logger, method, event_dict): + return event_dict + + import zcore.logging.config as logging_config + importlib.reload(logging_config) + + with patch("structlog.configure") as mock_configure: + logging_config.setup_logging( + custom_processors=[dummy_proc], + extra_handlers=[dummy_handler], + ) + root_logger = logging.getLogger() + assert dummy_handler in root_logger.handlers + assert dummy_handler.formatter is not None + + mock_configure.assert_called_once() + configured_procs = mock_configure.call_args[1].get("processors", []) + assert dummy_proc in configured_procs \ No newline at end of file From f415f693c716186febdf2f262cd4fd375614df62 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 17:18:23 +0330 Subject: [PATCH 59/82] test(di): add background_task AsyncSession warning tests - Added test for background_task warning when AsyncSession is passed as positional argument - Added test for background_task warning when AsyncSession is passed as keyword argument - Added test for synchronous background_task with AsyncSession warning - Added missing blank lines throughout file --- tests/test_di.py | 137 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 109 insertions(+), 28 deletions(-) diff --git a/tests/test_di.py b/tests/test_di.py index e3ddf2c..c03854f 100644 --- a/tests/test_di.py +++ b/tests/test_di.py @@ -4,6 +4,7 @@ from collections.abc import Generator from contextlib import contextmanager from typing import Annotated, Any, get_args, get_origin +from unittest.mock import AsyncMock, patch import pytest from fastapi.params import Depends as DependsClass @@ -27,76 +28,95 @@ class IService: pass + class ServiceImpl(IService): def __init__(self) -> None: self.id = uuid.uuid4() + class AnotherServiceImpl(IService): def __init__(self) -> None: self.id = uuid.uuid4() + class ForwardA: def __init__(self, b: "ForwardB") -> None: self.b = b + class ForwardB: def __init__(self) -> None: self.id = uuid.uuid4() + class CircA: def __init__(self, b: "CircB") -> None: self.b = b + class CircB: def __init__(self, a: "CircA") -> None: self.a = a + class AbstractService(ABC): pass + class ConcreteService(AbstractService): def __init__(self) -> None: self.id = uuid.uuid4() + class AutoZ: def __init__(self) -> None: self.id = uuid.uuid4() + class AutoY: def __init__(self, z: AutoZ) -> None: self.z = z + class AutoX: def __init__(self, y: AutoY) -> None: self.y = y + class SimpleNoInit: pass + class DepWithAnnotated: def __init__(self, dep: Annotated[ForwardB, "meta"]) -> None: self.dep = dep + class DeepA: def __init__(self, b: "DeepB") -> None: self.b = b + class DeepB: def __init__(self, c: "DeepC") -> None: self.c = c + class DeepC: def __init__(self, a: "DeepA") -> None: self.a = a + class BackgroundTaskWorker: def __init__(self) -> None: self.id = uuid.uuid4() + class SecondaryWorker: def __init__(self) -> None: self.id = uuid.uuid4() + @contextmanager def di_scope(scope_id: str) -> Generator[None, None, None]: token_id = _current_scope_id.set(scope_id) @@ -107,6 +127,7 @@ def di_scope(scope_id: str) -> Generator[None, None, None]: _current_scope_id.reset(token_id) _scoped_instances.reset(token_instances) + @pytest.fixture(autouse=True) def reset_container() -> None: container._singletons.clear() @@ -115,6 +136,7 @@ def reset_container() -> None: container._constructor_cache.clear() container._dependency_signature_cache.clear() + @pytest.mark.parametrize( "interface, implementation", [ @@ -125,14 +147,15 @@ def reset_container() -> None: def test_resolve_singleton(interface: type[Any], implementation: type[Any]) -> None: instance = implementation() container.register_singleton(interface, instance) - + res1 = container.resolve(interface) res2 = container.resolve(interface) - + assert res1 is instance assert res2 is instance assert res1.id == res2.id + @pytest.mark.parametrize( "interface, implementation, scope_1, scope_2", [ @@ -147,16 +170,17 @@ def test_resolve_scoped( scope_2: str ) -> None: container.register_scoped(interface, implementation) - + with di_scope(scope_1): res1 = container.resolve(interface) res2 = container.resolve(interface) assert res1 is res2 - + with di_scope(scope_2): res3 = container.resolve(interface) assert res3 is not res1 + @pytest.mark.parametrize( "interface, implementation", [ @@ -166,14 +190,15 @@ def test_resolve_scoped( ) def test_resolve_transient(interface: type[Any], implementation: type[Any]) -> None: container.register_transient(interface, implementation) - + res1 = container.resolve(interface) res2 = container.resolve(interface) - + assert res1 is not res2 assert isinstance(res1, implementation) assert isinstance(res2, implementation) + @pytest.mark.parametrize( "registrations", [ @@ -183,14 +208,15 @@ def test_resolve_transient(interface: type[Any], implementation: type[Any]) -> N def test_circular_dependency(registrations: dict[str, type[Any]]) -> None: container.register_transient(CircA, registrations["A"]) container.register_transient(CircB, registrations["B"]) - + with pytest.raises(CircularDependencyError) as exc_info: container.resolve(CircA) - + assert "Circular dependency detected" in str(exc_info.value) assert "CircA" in str(exc_info.value) assert "CircB" in str(exc_info.value) + @pytest.mark.parametrize( "target, dep", [ @@ -200,16 +226,17 @@ def test_circular_dependency(registrations: dict[str, type[Any]]) -> None: def test_forward_reference_resolution(target: type[Any], dep: type[Any]) -> None: container.register_transient(dep, dep) container.register_transient(target, target) - + resolved = container.resolve(target) assert isinstance(resolved, target) assert isinstance(resolved.b, dep) + @pytest.mark.anyio @pytest.mark.parametrize("num_tasks", [10, 50]) async def test_di_concurrency_isolation(num_tasks: int) -> None: container.register_scoped(IService, ServiceImpl) - + async def run_task(task_id: str) -> tuple[str, Any]: with di_scope(task_id): res1 = container.resolve(IService) @@ -217,16 +244,17 @@ async def run_task(task_id: str) -> tuple[str, Any]: res2 = container.resolve(IService) assert res1 is res2 return task_id, res1 - + tasks = [run_task(f"scope-{uuid.uuid4()}") for _ in range(num_tasks)] results = await asyncio.gather(*tasks) - + seen_ids = set() for _task_id, instance in results: assert instance.id not in seen_ids seen_ids.add(instance.id) assert len(seen_ids) == num_tasks + def test_register_scoped_instance_success() -> None: instance = ServiceImpl() with di_scope("active_scope"): @@ -234,22 +262,26 @@ def test_register_scoped_instance_success() -> None: resolved = container.resolve(IService) assert resolved is instance + def test_register_scoped_instance_outside_scope_error() -> None: instance = ServiceImpl() with pytest.raises(DIException) as exc_info: container.register_scoped_instance(IService, instance) assert "Cannot register scoped instance outside of an active scope" in str(exc_info.value) + def test_auto_wire_implicit_resolution() -> None: resolved = container.resolve(ForwardB) assert isinstance(resolved, ForwardB) + def test_auto_wire_recursive() -> None: resolved = container.resolve(AutoX) assert isinstance(resolved, AutoX) assert isinstance(resolved.y, AutoY) assert isinstance(resolved.y.z, AutoZ) + def test_constructor_and_signature_cache() -> None: assert ForwardA not in container._constructor_cache assert ForwardA not in container._dependency_signature_cache @@ -260,6 +292,7 @@ def test_constructor_and_signature_cache() -> None: assert ForwardA in container._dependency_signature_cache assert container._dependency_signature_cache[ForwardA] == [ForwardB] + @pytest.mark.anyio async def test_injector_callable_helper() -> None: container.register_transient(IService, ServiceImpl) @@ -267,6 +300,7 @@ async def test_injector_callable_helper() -> None: resolved = await injector_instance() assert isinstance(resolved, ServiceImpl) + def test_inject_syntactic_sugar() -> None: injected = Inject[IService] assert get_origin(injected) is Annotated @@ -277,17 +311,20 @@ def test_inject_syntactic_sugar() -> None: assert isinstance(depends_dep.dependency, Injector) assert depends_dep.dependency.interface is IService + def test_interface_to_implementation_binding() -> None: container.register_transient(AbstractService, ConcreteService) resolved = container.resolve(AbstractService) assert isinstance(resolved, ConcreteService) + def test_class_no_init_or_object_init() -> None: resolved = container.resolve(SimpleNoInit) assert isinstance(resolved, SimpleNoInit) assert SimpleNoInit in container._dependency_signature_cache assert container._dependency_signature_cache[SimpleNoInit] == [] + def test_constructor_annotated_parameters() -> None: container.register_transient(ForwardB, ForwardB) container.register_transient(DepWithAnnotated, DepWithAnnotated) @@ -295,6 +332,7 @@ def test_constructor_annotated_parameters() -> None: assert isinstance(resolved, DepWithAnnotated) assert isinstance(resolved.dep, ForwardB) + def test_clear_scope_explicit() -> None: container.register_scoped(IService, ServiceImpl) scope_id = "test_clear" @@ -306,6 +344,7 @@ def test_clear_scope_explicit() -> None: res3 = container.resolve(IService) assert res3 is not res1 + def test_deep_circular_dependency() -> None: container.register_transient(DeepA, DeepA) container.register_transient(DeepB, DeepB) @@ -317,102 +356,108 @@ def test_deep_circular_dependency() -> None: assert "DeepB" in str(exc_info.value) assert "DeepC" in str(exc_info.value) + @pytest.mark.anyio async def test_background_scope_lifecycle_and_isolation() -> None: if not db_manager._engine: db_manager.init_app("sqlite+aiosqlite:///:memory:") - + parent_ctx_token = _request_context_store.set({"user_id": "parent-user", "role": "admin"}) - + try: assert _current_scope_id.get() is None - + async with background_scope(inherit_context=True, custom_flag=True): assert _current_scope_id.get() is not None session = container.resolve(AsyncSession) assert isinstance(session, AsyncSession) - + current_store = _request_context_store.get() assert current_store.get("user_id") == "parent-user" assert current_store.get("custom_flag") is True - + assert _current_scope_id.get() is None assert _request_context_store.get().get("custom_flag") is None assert _request_context_store.get().get("user_id") == "parent-user" finally: _request_context_store.reset(parent_ctx_token) + @pytest.mark.anyio async def test_background_scope_no_inherit_context() -> None: if not db_manager._engine: db_manager.init_app("sqlite+aiosqlite:///:memory:") parent_ctx_token = _request_context_store.set({"user_id": "isolated-user", "tenant": "corp"}) - + try: async with background_scope(inherit_context=False, task_only_key="task-value"): current_store = _request_context_store.get() assert current_store.get("user_id") is None assert current_store.get("tenant") is None assert current_store.get("task_only_key") == "task-value" - + assert _request_context_store.get().get("user_id") == "isolated-user" assert _request_context_store.get().get("tenant") == "corp" finally: _request_context_store.reset(parent_ctx_token) + @pytest.mark.anyio async def test_background_scope_exception_cleanup_guarantee() -> None: if not db_manager._engine: db_manager.init_app("sqlite+aiosqlite:///:memory:") token = _request_context_store.set({"initial": "state"}) - + try: with pytest.raises(ValueError, match="Scope breakdown"): async with background_scope(inherit_context=True, err_state=True): assert _current_scope_id.get() is not None raise ValueError("Scope breakdown") - + assert _current_scope_id.get() is None assert _request_context_store.get() == {"initial": "state"} finally: _request_context_store.reset(token) + @pytest.mark.anyio async def test_background_task_decorator_async_execution() -> None: if not db_manager._engine: db_manager.init_app("sqlite+aiosqlite:///:memory:") - + container.register_scoped(BackgroundTaskWorker, BackgroundTaskWorker) - + @background_task async def sample_async_job(target_name: str, worker: BackgroundTaskWorker) -> dict[str, Any]: session = container.resolve(AsyncSession) assert isinstance(session, AsyncSession) return {"name": target_name, "worker_id": worker.id} - + result = await sample_async_job("task-alpha") assert result["name"] == "task-alpha" assert isinstance(result["worker_id"], uuid.UUID) + @pytest.mark.anyio async def test_background_task_decorator_sync_execution() -> None: if not db_manager._engine: db_manager.init_app("sqlite+aiosqlite:///:memory:") - + container.register_scoped(BackgroundTaskWorker, BackgroundTaskWorker) - + @background_task def sample_sync_job(value: int, worker: BackgroundTaskWorker) -> int: session = container.resolve(AsyncSession) assert isinstance(session, AsyncSession) assert isinstance(worker.id, uuid.UUID) return value * 2 - + computed = await sample_sync_job(21) assert computed == 42 + @pytest.mark.anyio async def test_background_task_explicit_override_arguments() -> None: if not db_manager._engine: @@ -428,6 +473,7 @@ async def override_job(worker: BackgroundTaskWorker) -> uuid.UUID: result_id = await override_job(worker=explicit_worker) assert result_id == explicit_worker.id + @pytest.mark.anyio async def test_background_task_multiple_injections() -> None: if not db_manager._engine: @@ -449,6 +495,7 @@ async def multi_dep_job( assert isinstance(id2, uuid.UUID) assert tag == "default_tag" + @pytest.mark.anyio async def test_background_task_concurrent_isolation() -> None: if not db_manager._engine: @@ -469,4 +516,38 @@ async def concurrent_job(idx: int, worker: BackgroundTaskWorker) -> tuple[int, u session_memory_ids = {r[2] for r in results} assert len(worker_ids) == 10 - assert len(session_memory_ids) == 10 \ No newline at end of file + assert len(session_memory_ids) == 10 + + +@pytest.mark.anyio +async def test_background_task_warns_when_async_session_passed() -> None: + if not db_manager._engine: + db_manager.init_app("sqlite+aiosqlite:///:memory:") + + mock_session = AsyncMock(spec=AsyncSession) + + @background_task + async def async_job_with_session(session: AsyncSession, name: str) -> str: + return name + + @background_task + def sync_job_with_session(session: AsyncSession, count: int) -> int: + return count + + with patch("zcore.kernel.di.logger.warning") as mock_warn: + res_pos = await async_job_with_session(mock_session, "pos_call") + assert res_pos == "pos_call" + mock_warn.assert_called_once() + assert "Passing an existing AsyncSession directly" in mock_warn.call_args[0][0] + + with patch("zcore.kernel.di.logger.warning") as mock_warn: + res_kw = await async_job_with_session(session=mock_session, name="kw_call") + assert res_kw == "kw_call" + mock_warn.assert_called_once() + assert "Passing an existing AsyncSession directly via argument 'session'" in mock_warn.call_args[0][0] + + with patch("zcore.kernel.di.logger.warning") as mock_warn: + res_sync = await sync_job_with_session(mock_session, 100) + assert res_sync == 100 + mock_warn.assert_called_once() + assert "Passing an existing AsyncSession directly" in mock_warn.call_args[0][0] \ No newline at end of file From 2e75aa439c411c3a49c01f3f0fa5afbcadd7974e Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 17:20:02 +0330 Subject: [PATCH 60/82] test(cache): add tests for eviction task safety and close cancellation - Added test for _ensure_eviction_task outside event loop (RuntimeError suppression) - Added test for close_cache with eviction task cancellation and aclose assertion - Added missing blank lines throughout file --- tests/test_cache.py | 106 ++++++++++++++++++++++++++++++-------------- 1 file changed, 72 insertions(+), 34 deletions(-) diff --git a/tests/test_cache.py b/tests/test_cache.py index 9b4b22b..34fef18 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -2,12 +2,13 @@ import threading import time from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest from pydantic import BaseModel -from zcore.cache.base import BaseCache, close_cache, init_cache +import zcore.cache.base as base_module +from zcore.cache.base import BaseCache, _ensure_eviction_task, close_cache, init_cache from zcore.cache.ttllru_cache import TTLLRUCache, _active_caches @@ -15,6 +16,7 @@ class SampleCachedModel(BaseModel): id: int name: str + @pytest.mark.parametrize( "ttl_a, ttl_b, time_advancement, expected_a, expected_b", [ @@ -51,10 +53,11 @@ def test_ttllru_eviction_and_cleanup( current_time += 200 TTLLRUCache.evict_all_expired() - + with cache._lock: assert "c" not in cache.cache + @pytest.mark.anyio @pytest.mark.parametrize( "redis_healthy, simulate_exception", @@ -69,7 +72,7 @@ async def test_base_cache_redis_fallback( simulate_exception: bool ) -> None: cache = BaseCache[str](prefix="fallback_test") - + if not redis_healthy: monkeypatch.setattr("zcore.cache.base._shared_redis_client", None) assert cache.redis_client is None @@ -81,10 +84,11 @@ async def test_base_cache_redis_fallback( monkeypatch.setattr("zcore.cache.base._shared_redis_client", mock_client) await cache.set("safety_key", "secure_value", ttl=10) - + val = await cache.get("safety_key") assert val == "secure_value" + @pytest.mark.anyio @pytest.mark.parametrize( "payload, target_type, expected_cls", @@ -101,17 +105,18 @@ async def test_cache_deserialization_types( ) -> None: cache = BaseCache[Any](prefix="typing_test") await cache.set("payload_key", payload, ttl=5) - + retrieved = await cache.get("payload_key", target_type=target_type) assert retrieved is not None assert isinstance(retrieved, expected_cls) - + if target_type: assert retrieved.id == payload["id"] assert retrieved.name == payload["name"] else: assert retrieved == payload + def test_ttllru_lru_eviction() -> None: cache = TTLLRUCache(maxsize=3) cache.set("k1", "v1") @@ -124,23 +129,25 @@ def test_ttllru_lru_eviction() -> None: assert cache.get("k3") == "v3" assert cache.get("k4") == "v4" + def test_ttllru_inline_eviction_on_get(monkeypatch: pytest.MonkeyPatch) -> None: current_time = 1000.0 monkeypatch.setattr("time.time", lambda: current_time) monkeypatch.setattr("zcore.cache.ttllru_cache.time.time", lambda: current_time) - + cache = TTLLRUCache(maxsize=10) cache.set("key", "val", ttl=5) - + current_time += 10 assert cache.get("key") is None - + with cache._lock: assert "key" not in cache.cache + def test_ttllru_thread_safety() -> None: cache = TTLLRUCache(maxsize=100) - + def worker(worker_id: int) -> None: for i in range(50): key = f"key_{worker_id}_{i}" @@ -150,101 +157,106 @@ def worker(worker_id: int) -> None: cache.delete(key) cache.evict_expired() time.sleep(0.001) - + threads = [] for t_id in range(5): t = threading.Thread(target=worker, args=(t_id,)) threads.append(t) t.start() - + for t in threads: t.join() + def test_ttllru_weakref_gc() -> None: gc.collect() initial_count = len(list(_active_caches)) - + def create_temporary_cache() -> None: _ = TTLLRUCache(maxsize=5) assert len(list(_active_caches)) == initial_count + 1 - + create_temporary_cache() gc.collect() assert len(list(_active_caches)) == initial_count + def test_ttllru_overwrite_resets_ttl(monkeypatch: pytest.MonkeyPatch) -> None: current_time = 1000.0 monkeypatch.setattr("time.time", lambda: current_time) monkeypatch.setattr("zcore.cache.ttllru_cache.time.time", lambda: current_time) - + cache = TTLLRUCache() cache.set("k1", "v1", ttl=5) cache.set("k1", "v2", ttl=100) - + current_time += 10 assert cache.get("k1") == "v2" + def test_ttllru_explicit_delete() -> None: cache = TTLLRUCache() cache.set("k1", "v1") assert cache.get("k1") == "v1" cache.delete("k1") assert cache.get("k1") is None - + with cache._lock: assert "k1" not in cache.cache + @pytest.mark.anyio async def test_base_cache_prefix_isolation(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("zcore.cache.base._shared_redis_client", None) - + cache_users = BaseCache[str](prefix="users") cache_products = BaseCache[str](prefix="products") - + await cache_users.set("1", "alice") await cache_products.set("1", "laptop") - + assert await cache_users.get("1") == "alice" assert await cache_products.get("1") == "laptop" + @pytest.mark.anyio async def test_cache_global_lifecycle(monkeypatch: pytest.MonkeyPatch) -> None: - import zcore.cache.base as base_module monkeypatch.setattr(base_module, "_shared_redis_client", None) monkeypatch.setattr(base_module, "_eviction_task", None) - + init_cache(redis_url="redis://localhost:6379") - + assert base_module._eviction_task is not None assert not base_module._eviction_task.done() - + await close_cache() - + assert base_module._eviction_task is None assert base_module._shared_redis_client is None + @pytest.mark.anyio async def test_cache_double_init(monkeypatch: pytest.MonkeyPatch) -> None: - import zcore.cache.base as base_module monkeypatch.setattr(base_module, "_shared_redis_client", None) monkeypatch.setattr(base_module, "_eviction_task", None) - + init_cache() task1 = base_module._eviction_task assert task1 is not None - + init_cache() task2 = base_module._eviction_task assert task2 is task1 - + await close_cache() + @pytest.mark.anyio @pytest.mark.parametrize("redis_healthy", [True, False]) async def test_base_cache_delete_fallback(monkeypatch: pytest.MonkeyPatch, redis_healthy: bool) -> None: cache = BaseCache[str](prefix="del_test") await cache.set("k1", "v1") - + if redis_healthy: mock_client = AsyncMock() monkeypatch.setattr("zcore.cache.base._shared_redis_client", mock_client) @@ -258,12 +270,38 @@ async def test_base_cache_delete_fallback(monkeypatch: pytest.MonkeyPatch, redis await cache.delete("k1") assert await cache.get("k1") is None + @pytest.mark.anyio async def test_base_cache_corrupt_json_deserialization(monkeypatch: pytest.MonkeyPatch) -> None: cache = BaseCache[Any](prefix="corrupt_test") monkeypatch.setattr("zcore.cache.base._shared_redis_client", None) - + cache._local_cache.set("corrupt_test:corrupt_key", "{invalid_json_string}") - + val = await cache.get("corrupt_key") - assert val is None \ No newline at end of file + assert val is None + + +def test_ensure_eviction_task_outside_event_loop_safe(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(base_module, "_eviction_task", None) + with patch("asyncio.get_running_loop", side_effect=RuntimeError("no running event loop")): + _ensure_eviction_task() + assert base_module._eviction_task is None + + +@pytest.mark.anyio +async def test_close_cache_cancellation_suppression(monkeypatch: pytest.MonkeyPatch) -> None: + mock_redis = AsyncMock() + monkeypatch.setattr(base_module, "_shared_redis_client", mock_redis) + monkeypatch.setattr(base_module, "_eviction_task", None) + + base_module._ensure_eviction_task() + task = base_module._eviction_task + assert task is not None + assert not task.done() + + await close_cache() + + assert base_module._eviction_task is None + assert base_module._shared_redis_client is None + mock_redis.aclose.assert_called_once() \ No newline at end of file From 5a7d066844a09b5eda9450bdcc602405307bea13 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 17:23:49 +0330 Subject: [PATCH 61/82] test(kernel): add lifespan DB and cache cleanup test - Added test_kernel_lifespan_closes_db_and_cache_on_shutdown with mocked close_cache and db_manager.close - Verifies cleanup functions are called only after lifespan exits - Added missing blank lines throughout file --- tests/test_kernel.py | 73 +++++++++++++++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/tests/test_kernel.py b/tests/test_kernel.py index 21490cb..fc18f7d 100644 --- a/tests/test_kernel.py +++ b/tests/test_kernel.py @@ -1,4 +1,5 @@ import uuid +from unittest.mock import AsyncMock, patch import pytest from fastapi import FastAPI @@ -10,9 +11,9 @@ class TrackedPlugin: def __init__( - self, - name: str, - dependencies: list[str], + self, + name: str, + dependencies: list[str], log: list[str], fail_phase: str | None = None ) -> None: @@ -47,6 +48,7 @@ async def on_shutdown(self) -> None: raise ValueError("on_shutdown failure") self.log.append(f"{self.name}:on_shutdown") + @pytest.mark.parametrize( "graph", [ @@ -59,15 +61,16 @@ def test_topological_sort_order(graph: dict[str, list[str]]) -> None: log: list[str] = [] for name, deps in graph.items(): kernel.add_plugin(TrackedPlugin(name, deps, log)) - + resolved = kernel._resolve_dependencies() resolved_names = [p.name for p in resolved] - + for name, deps in graph.items(): name_idx = resolved_names.index(name) for dep in deps: assert resolved_names.index(dep) < name_idx + @pytest.mark.parametrize( "graph", [ @@ -80,11 +83,12 @@ def test_cycle_dependency_error(graph: dict[str, list[str]]) -> None: log: list[str] = [] for name, deps in graph.items(): kernel.add_plugin(TrackedPlugin(name, deps, log)) - + with pytest.raises(RuntimeError) as exc_info: kernel._resolve_dependencies() assert "Cyclic dependency" in str(exc_info.value) + @pytest.mark.parametrize( "graph", [ @@ -97,11 +101,12 @@ def test_missing_dependency_error(graph: dict[str, list[str]]) -> None: log: list[str] = [] for name, deps in graph.items(): kernel.add_plugin(TrackedPlugin(name, deps, log)) - + with pytest.raises(RuntimeError) as exc_info: kernel._resolve_dependencies() assert "Missing dependency" in str(exc_info.value) + @pytest.mark.anyio @pytest.mark.parametrize( "graph", @@ -113,15 +118,15 @@ async def test_plugin_lifespan_lifecycle(graph: dict[str, list[str]]) -> None: kernel = Kernel() log: list[str] = [] app = FastAPI() - + for name, deps in graph.items(): kernel.add_plugin(TrackedPlugin(name, deps, log)) - + kernel.setup(app) - + assert log == ["A:setup", "B:setup", "C:setup"] log.clear() - + async with kernel.lifespan(app): assert log == [ "A:before_startup", "B:before_startup", "C:before_startup", @@ -129,11 +134,12 @@ async def test_plugin_lifespan_lifecycle(graph: dict[str, list[str]]) -> None: "A:after_startup", "B:after_startup", "C:after_startup", ] log.clear() - + assert log == [ "C:on_shutdown", "B:on_shutdown", "A:on_shutdown" ] + @pytest.mark.anyio async def test_empty_kernel() -> None: kernel = Kernel() @@ -143,6 +149,7 @@ async def test_empty_kernel() -> None: async with kernel.lifespan(app): pass + def test_self_dependency_error() -> None: kernel = Kernel() log: list[str] = [] @@ -151,6 +158,7 @@ def test_self_dependency_error() -> None: kernel._resolve_dependencies() assert "Cyclic dependency" in str(exc_info.value) + def test_multiple_independent_chains() -> None: kernel = Kernel() log: list[str] = [] @@ -163,6 +171,7 @@ def test_multiple_independent_chains() -> None: assert names.index("A") < names.index("B") assert names.index("C") < names.index("D") + def test_redundant_dependencies() -> None: kernel = Kernel() log: list[str] = [] @@ -173,6 +182,7 @@ def test_redundant_dependencies() -> None: names = [p.name for p in resolved] assert names == ["A", "B", "C"] + @pytest.mark.anyio async def test_exception_in_before_startup() -> None: kernel = Kernel() @@ -188,6 +198,7 @@ async def test_exception_in_before_startup() -> None: assert "B:before_startup" not in log assert "A:on_startup" not in log + @pytest.mark.anyio async def test_exception_in_on_startup() -> None: kernel = Kernel() @@ -202,6 +213,7 @@ async def test_exception_in_on_startup() -> None: assert "A:on_startup" not in log assert "A:after_startup" not in log + @pytest.mark.anyio async def test_exception_in_after_startup() -> None: kernel = Kernel() @@ -215,6 +227,7 @@ async def test_exception_in_after_startup() -> None: assert "A:on_startup" in log assert "A:after_startup" not in log + @pytest.mark.anyio async def test_shutdown_failures_robustness() -> None: kernel = Kernel() @@ -229,6 +242,7 @@ async def test_shutdown_failures_robustness() -> None: assert "B:on_shutdown" not in log assert "A:on_shutdown" not in log + def test_event_dispatcher_registration() -> None: kernel = Kernel() app = FastAPI() @@ -236,6 +250,7 @@ def test_event_dispatcher_registration() -> None: resolved_dispatcher = container.resolve(EventDispatcher) assert resolved_dispatcher is kernel.dispatcher + @pytest.mark.anyio async def test_lifespan_lazy_resolution() -> None: kernel = Kernel() @@ -245,12 +260,13 @@ async def test_lifespan_lazy_resolution() -> None: async with kernel.lifespan(app): assert "A:before_startup" in log + @pytest.mark.anyio async def test_inter_plugin_event_dispatching() -> None: kernel = Kernel() log: list[str] = [] app = FastAPI() - + class EventPlugin: def __init__(self, name: str, kernel_inst: Kernel, log_list: list[str]) -> None: self.name = name @@ -258,24 +274,45 @@ def __init__(self, name: str, kernel_inst: Kernel, log_list: list[str]) -> None: self.dependencies: list[str] = [] self.kernel = kernel_inst self.log = log_list - + def setup(self, app_inst: FastAPI) -> None: pass + async def before_startup(self) -> None: pass + async def on_startup(self) -> None: await self.kernel.dispatcher.dispatch("custom_event", "event_payload") + async def after_startup(self) -> None: pass + async def on_shutdown(self) -> None: pass - + async def handler(payload: str) -> None: log.append(payload) - + kernel.dispatcher.subscribe("custom_event", handler) kernel.add_plugin(EventPlugin("A", kernel, log)) kernel.setup(app) - + async with kernel.lifespan(app): - assert log == ["event_payload"] \ No newline at end of file + assert log == ["event_payload"] + + +@pytest.mark.anyio +async def test_kernel_lifespan_closes_db_and_cache_on_shutdown() -> None: + kernel = Kernel() + app = FastAPI() + kernel.setup(app) + + with patch("zcore.cache.base.close_cache", new_callable=AsyncMock) as mock_close_cache, \ + patch("zcore.db.setup.db_manager.close", new_callable=AsyncMock) as mock_db_close: + + async with kernel.lifespan(app): + mock_close_cache.assert_not_called() + mock_db_close.assert_not_called() + + mock_close_cache.assert_called_once() + mock_db_close.assert_called_once() \ No newline at end of file From 40323c1887574b6178a782099b165adfa11f2961 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 17:25:56 +0330 Subject: [PATCH 62/82] test(cli): add version output and DatabaseSettings template tests - Added test for CLI version output confirming 0.1.0-beta.9 - Added test for print_banner version consistency - Added test for scaffold templates using DatabaseSettings config in main.py - Added test for TIMEZONE in .env template --- tests/test_cli.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 7d3c30c..66349ba 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,6 +7,7 @@ from zcore.cli import main from zcore.cli.commands import find_zcore_projects, is_zcore_project +from zcore.cli.ui import console, print_banner @pytest.fixture @@ -265,4 +266,33 @@ def test_scaffolded_templates_are_valid_python( for py_file in app_dir.glob("*.py"): code = py_file.read_text(encoding="utf-8") if code.strip(): - compile(code, str(py_file), "exec") \ No newline at end of file + compile(code, str(py_file), "exec") + + +def test_cli_version_output_beta_9() -> None: + with console.capture() as capture: + test_args = ["zc", "--version"] + with patch.object(sys, "argv", test_args): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + assert "0.1.0-beta.9" in capture.get() + + with console.capture() as capture: + print_banner() + assert "0.1.0-beta.9" in capture.get() + + +def test_cli_scaffold_templates_use_database_settings_config(run_in_tmp_path: Path) -> None: + project_name = "db_cfg_proj" + test_args = ["zc", "init", project_name, "-y"] + + with patch.object(sys, "argv", test_args): + main() + + project_dir = run_in_tmp_path / project_name + main_py_content = (project_dir / "main.py").read_text(encoding="utf-8") + env_content = (project_dir / ".env").read_text(encoding="utf-8") + + assert "db_manager.init_app(config=settings.DATABASE)" in main_py_content + assert 'TIMEZONE="UTC"' in env_content \ No newline at end of file From d81417360c22c9fbdeacea528f022d19d1453a5b Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 17:34:13 +0330 Subject: [PATCH 63/82] deps: add tzdata dependency for Windows timezone support - Added tzdata>=2024.1 to base dependencies for Windows platform - Added tzdata to all optional dependencies group --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 0634e4c..0e52628 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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,6 +81,7 @@ all = [ "aiofiles>=23.2.1", "argon2-cffi>=23.1.0", "pyjwt>=2.8.0", + "tzdata>=2024.1; sys_platform == 'win32'", ] [project.urls] From 6e5165df6265a30db1a7d70a94c127e0b3f5781e Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sat, 5 Sep 2026 17:35:19 +0330 Subject: [PATCH 64/82] docs(readme): update CLI banner version to 0.1.0-beta.9 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0f26614..7dee5cb 100644 --- a/README.md +++ b/README.md @@ -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-beta.9 • Modern Modular Monolith FastAPI • SQLAlchemy 2.0 • Pydantic V2 ? What framework task would you like to perform? 📦 init — Scaffold a new full ZCore project From 63cf57d0e75a4f6f81c9e7976d1392e63c2e6776 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sun, 6 Sep 2026 11:37:26 +0330 Subject: [PATCH 65/82] refactor(timezone): move timezone formatting to ZDateTime type and simplify Zchema - Removed timezone conversion logic from Zchema._prune_data - Removed AUTO_CONVERT_TIMEZONE handling from Zchema serializer - format_iso_with_app_timezone now respects AUTO_CONVERT_TIMEZONE setting - Updated Zchema timezone test to use ZDateTime type and model_dump(mode='json') --- src/zcore/utils/timezone.py | 4 ++++ src/zcore/web/projection.py | 43 +++++++++++-------------------------- tests/test_web.py | 8 ++++--- 3 files changed, 22 insertions(+), 33 deletions(-) diff --git a/src/zcore/utils/timezone.py b/src/zcore/utils/timezone.py index 4081910..481f140 100644 --- a/src/zcore/utils/timezone.py +++ b/src/zcore/utils/timezone.py @@ -90,6 +90,10 @@ def format_iso_with_app_timezone(dt: datetime | None) -> str | None: """ if dt is None: return None + + if not getattr(settings, "AUTO_CONVERT_TIMEZONE", True): + return dt.isoformat() + converted = to_app_timezone(dt) return converted.isoformat() if converted is not None else None diff --git a/src/zcore/web/projection.py b/src/zcore/web/projection.py index 35ce10e..0bf9e7e 100644 --- a/src/zcore/web/projection.py +++ b/src/zcore/web/projection.py @@ -1,18 +1,15 @@ """Unified Schema and Response Pruning Hub. This module provides the core `Zchema` base class, which integrates Pydantic V2 -dynamic JSON schema generation, input validation, automatic timezone conversions, -and response serialization filtering based on domain-isolated context restriction definitions. +dynamic JSON schema generation, input validation, and response serialization +filtering based on domain-isolated context restriction definitions. """ -from datetime import datetime from typing import Any, ClassVar from pydantic import BaseModel, model_serializer, model_validator -from zcore.config import settings from zcore.context.context import ctx -from zcore.utils.timezone import format_iso_with_app_timezone class Zchema(BaseModel): @@ -20,7 +17,7 @@ class Zchema(BaseModel): Subclasses specify their unique database domain mapping via the `__model__` class attribute. This enables contextual, recursive pruning across schema generation, - input validation, automated timezone formatting, and response serialization. + input validation, and response serialization. """ __model__: ClassVar[str | None] = None @@ -60,9 +57,8 @@ def _prune_data( cls, data: Any, relative_paths: set[str], - auto_convert_tz: bool = False, ) -> Any: - """Recursively strip restricted attributes and format datetimes with app timezone.""" + """Recursively strip restricted attributes from dictionary representations.""" if not isinstance(data, dict): return data @@ -83,32 +79,22 @@ def _prune_data( for key in list(data.keys()): val = data[key] - if auto_convert_tz and isinstance(val, datetime): - data[key] = format_iso_with_app_timezone(val) - elif key in nested_restrictions: + if key in nested_restrictions: rem_paths = nested_restrictions[key] if isinstance(val, dict): - data[key] = cls._prune_data( - val, rem_paths, auto_convert_tz=auto_convert_tz - ) + data[key] = cls._prune_data(val, rem_paths) elif isinstance(val, list): data[key] = [ - cls._prune_data( - item, rem_paths, auto_convert_tz=auto_convert_tz - ) + cls._prune_data(item, rem_paths) if isinstance(item, dict) else item for item in val ] elif isinstance(val, dict): - data[key] = cls._prune_data( - val, set(), auto_convert_tz=auto_convert_tz - ) + data[key] = cls._prune_data(val, set()) elif isinstance(val, list): data[key] = [ - cls._prune_data( - item, set(), auto_convert_tz=auto_convert_tz - ) + cls._prune_data(item, set()) if isinstance(item, dict) else item for item in val @@ -167,21 +153,18 @@ def filter_restricted_inputs(cls, data: Any) -> Any: if isinstance(data, dict): data_copy = dict(data) - return cls._prune_data(data_copy, relative_paths, auto_convert_tz=False) + return cls._prune_data(data_copy, relative_paths) return data @model_serializer(mode="wrap") def secure_serializer(self, handler: Any) -> Any: - """Securely intercept serialization to prune restricted attributes and format timezones.""" + """Securely intercept serialization to prune restricted attributes.""" serialized = handler(self) relative_paths = self._get_relative_restricted_paths() - auto_convert_tz = getattr(settings, "AUTO_CONVERT_TIMEZONE", True) if serialized is None: return serialized - if isinstance(serialized, dict): + if isinstance(serialized, dict) and relative_paths: serialized_copy = dict(serialized) - return self._prune_data( - serialized_copy, relative_paths, auto_convert_tz=auto_convert_tz - ) + return self._prune_data(serialized_copy, relative_paths) return serialized \ No newline at end of file diff --git a/tests/test_web.py b/tests/test_web.py index 21e6bed..4fe57a1 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -597,6 +597,8 @@ class SpecificityRouter(BaseRouter[DummyCreate, DummyUpdate]): 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() @@ -606,12 +608,12 @@ def test_zchema_timezone_serialization_recursive(monkeypatch: pytest.MonkeyPatch class ItemDetail(Zchema): __model__ = "item" title: str - timestamp: datetime.datetime + timestamp: ZDateTime class OrderSummary(Zchema): __model__ = "order" order_id: str - created_at: datetime.datetime + created_at: ZDateTime items: list[ItemDetail] dt = datetime.datetime(2026, 7, 2, 10, 0, 0) @@ -621,7 +623,7 @@ class OrderSummary(Zchema): items=[ItemDetail(title="Widget", timestamp=dt)], ) - serialized = order.model_dump() + 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() From 7002916f2125dc84dc4f2d8f7c892225fbeab323 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sun, 6 Sep 2026 13:51:42 +0330 Subject: [PATCH 66/82] feat(config): add STORAGE_URL_PREFIX with AliasChoices support - Added STORAGE_URL_PREFIX with default '/storage' - Added alias support: STORAGE_URL_PREFIX and STORAGE_PREFIX - Added alias support for STORAGE_PATH: STORAGE_PATH and STORAGE_BASE_PATH - Simplified docstrings for initialize_settings, get_settings, SettingsProxy --- src/zcore/config.py | 40 +++++++++++----------------------------- 1 file changed, 11 insertions(+), 29 deletions(-) diff --git a/src/zcore/config.py b/src/zcore/config.py index cbb45ef..54357d4 100644 --- a/src/zcore/config.py +++ b/src/zcore/config.py @@ -10,7 +10,7 @@ import os from typing import Any, TypeVar, cast -from pydantic import BaseModel, Field, model_validator +from pydantic import AliasChoices, BaseModel, Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from zcore.kernel.di import container @@ -75,6 +75,7 @@ class Settings(BaseSettings): 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. """ @@ -101,7 +102,14 @@ class Settings(BaseSettings): 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 @@ -133,11 +141,6 @@ def _sync_settings(self) -> "Settings": 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. @@ -150,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. @@ -169,27 +169,9 @@ 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) From 76702144469afe9474d1e610f13a3afafca6e761 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sun, 6 Sep 2026 13:53:41 +0330 Subject: [PATCH 67/82] feat(storage): expand StorageProvider interface with exists and get_url methods - Added exists() abstract method for file existence verification - Added get_url() abstract method for resolving public web URLs - Updated upload/upload_stream return type docstrings to indicate web URL path - Added folder default value '' to upload and upload_stream - Updated delete parameter name to file_path_or_url --- src/zcore/storage/base.py | 62 +++++++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 19 deletions(-) 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 From a14686882488226cd08055f64d1d87c9e28a73e7 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sun, 6 Sep 2026 13:54:52 +0330 Subject: [PATCH 68/82] feat(storage): enhance validators with XSS and active content detection - Added BLOCKED_HEADER_PREFIXES for executable binaries (MZ, #!/, ELF) - Added MALICIOUS_CONTENT_PATTERNS for PHP, script tags, JS, VBS, HTML, iframe, object, embed, and event handlers - Increased sample read from 2048 to 8192 bytes for better pattern detection - Improved error messages with specific violation context - Updated logger messages to be more descriptive --- src/zcore/storage/validators.py | 81 +++++++++++++++++++++------------ 1 file changed, 51 insertions(+), 30 deletions(-) diff --git a/src/zcore/storage/validators.py b/src/zcore/storage/validators.py index 0401cfa..c949c6d 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(b"<\?php", re.IGNORECASE), + re.compile(b"]", re.IGNORECASE), + re.compile(b"javascript:", re.IGNORECASE), + re.compile(b"vbscript:", re.IGNORECASE), + re.compile(b"data:text/html", re.IGNORECASE), + re.compile(b"]", re.IGNORECASE), + re.compile(b"]", re.IGNORECASE), + re.compile(b"]", re.IGNORECASE), + re.compile(b"]", re.IGNORECASE), + re.compile(b"onload\s*=", re.IGNORECASE), + re.compile(b"onerror\s*=", re.IGNORECASE), + re.compile(b"onclick\s*=", re.IGNORECASE), + re.compile(b"xlink:href\s*=\s*[\"']javascript:", re.IGNORECASE), +) + class BaseStorageValidator: """Base interface protocol defining standard storage validators.""" @@ -46,7 +69,7 @@ class FileExtensionValidator(BaseStorageValidator): """Enforce extension checks on incoming file names. Attributes: - allowed_extensions: Normalized list of allowed file extensions (e.g., '.png'). + allowed_extensions: Normalized list of allowed file extensions. message: Diagnostic warning message dispatched on failures. """ @@ -54,7 +77,7 @@ def __init__( self, allowed_extensions: list[str] | set[str], message: str | None = None, - ): + ) -> None: """Initialize the FileExtensionValidator. Args: @@ -95,7 +118,7 @@ class MaxFileSizeValidator(BaseStorageValidator): message: Diagnostic warning message dispatched on failures. """ - def __init__(self, max_size_mb: float, message: str | None = None): + def __init__(self, max_size_mb: float, message: str | None = None) -> None: """Initialize the MaxFileSizeValidator. Args: @@ -115,12 +138,10 @@ def __call__(self, file: UploadFile) -> None: Raises: ValidationError: If the file size exceeds limits or cannot be verified. """ - # Utilize Starlette's native file.size if populated, fallback to seek evaluation size = getattr(file, "size", None) if size is None: try: - # Fallback seeking for chunked multipart parsers file.file.seek(0, 2) size = file.file.tell() file.file.seek(0) @@ -133,18 +154,17 @@ def __call__(self, file: UploadFile) -> None: class SafeMimeTypeValidator(BaseStorageValidator): - """MIME-type validator utilizing Magic Byte verification. + """MIME-type validator utilizing Magic Byte verification and active payload inspection. - Reads the header bytes of the file payload to verify that the file's content - matches its declared format. Blocks high-risk patterns such as PHP tag injections, - HTML script tags, Unix shebang scripts, and Windows executables. + Reads the initial byte buffers of the file payload to verify that content matches declared formats + and scans against active script injections, executable headers, and stored XSS vectors. Attributes: allowed_mimes: Set containing approved MIME keys. message: Diagnostic warning message dispatched on failures. """ - def __init__(self, allowed_mimes: list[str] | set[str], message: str | None = None): + def __init__(self, allowed_mimes: list[str] | set[str], message: str | None = None) -> None: """Initialize the SafeMimeTypeValidator. Args: @@ -158,7 +178,7 @@ def __init__(self, allowed_mimes: list[str] | set[str], message: str | None = No ) def __call__(self, file: UploadFile) -> None: - """Analyze file headers to detect masquerading extensions or script injections. + """Analyze file headers and byte payloads for security compliance. Args: file: The uploaded file payload to inspect. @@ -168,38 +188,39 @@ def __call__(self, file: UploadFile) -> None: unauthorized file signatures. """ try: - header_bytes = file.file.read(2048) + sample_bytes = file.file.read(8192) file.file.seek(0) except Exception as e: - logger.error(f"Failed to read magic bytes for verification: {e}") + logger.error(f"Failed to read file header bytes for verification: {e}") raise ValidationError(message="Failed to validate file signatures.") - # Blocks PHP block tags, HTML script tags, Unix shebang scripts and Windows Executables - if ( - b" Date: Sun, 6 Sep 2026 13:55:47 +0330 Subject: [PATCH 69/82] feat(storage): enhance LocalStorageProvider with URL resolution and exists method - Added url_prefix parameter and normalized URL handling - Added exists() method for file existence verification - Added get_url() method for normalized web URL resolution - Replaced generate_path with _resolve_destination returning (physical_path, web_url) - Added raw_base_path and base_path resolution with .mkdir() - Updated upload and upload_stream to return web URLs - Updated delete to accept both URLs and physical paths - Improved path cleaning and traversal protection logic --- src/zcore/storage/local.py | 199 +++++++++++++++++++++++-------------- 1 file changed, 124 insertions(+), 75 deletions(-) diff --git a/src/zcore/storage/local.py b/src/zcore/storage/local.py index 512053f..1c84c73 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,62 @@ 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) - async def generate_path(self, filename: str, related_type: str) -> str: - """Generate a secure, sanitized, and collision-resistant path for a new file. - - Enforces strict subdirectory constraints to mitigate path traversal exploits. + 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("\\", "/") + target_dir = (self.base_path / normalized_folder).resolve() - if not target_folder.is_relative_to(base): + if not target_dir.is_relative_to(self.base_path): raise AppException("Path traversal attempt detected") - uuid_file_name = str(uuid.uuid4())[:15] - ext = Path(filename).suffix.lower() + await Path(str(target_dir)).mkdir(parents=True, exist_ok=True) - new_file_name = f"{uuid_file_name}{ext}" - final_path = target_folder / new_file_name + ext = StdPath(filename).suffix.lower() + secure_filename = f"{uuid.uuid4().hex}{ext}" - if not await target_folder.exists(): - await target_folder.mkdir(parents=True, exist_ok=True) + physical_path = target_dir / secure_filename + relative_web_path = f"{normalized_folder}/{secure_filename}" if normalized_folder else secure_filename + web_url = f"{self.url_prefix}/{relative_web_path}" if self.url_prefix else f"/{relative_web_path}" - 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 +89,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 +105,125 @@ 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. + True if unlinking succeeds, False if traversal is detected or target is absent. """ + if not file_path_or_url: + return False + 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) + cleaned_path = file_path_or_url.replace("\\", "/").strip() + if self.url_prefix and cleaned_path.startswith(self.url_prefix): + cleaned_path = cleaned_path[len(self.url_prefix):].lstrip("/") + elif cleaned_path.startswith("/"): + cleaned_path = cleaned_path.lstrip("/") + + target_file = (self.base_path / cleaned_path).resolve() - if not path_to_delete.is_relative_to(base): + if not target_file.is_relative_to(self.base_path): logger.warning( - f"Prevented arbitrary file deletion attempt outside base path: {file_path}" + f"Prevented arbitrary file deletion attempt outside base path: {file_path_or_url}" ) return False - await path_to_delete.unlink(missing_ok=True) - return True + path_obj = Path(str(target_file)) + if await path_obj.exists(): + await path_obj.unlink() + return True + return False 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 + + try: + cleaned_path = file_path_or_url.replace("\\", "/").strip() + if self.url_prefix and cleaned_path.startswith(self.url_prefix): + cleaned_path = cleaned_path[len(self.url_prefix):].lstrip("/") + elif cleaned_path.startswith("/"): + cleaned_path = cleaned_path.lstrip("/") + + target_file = (self.base_path / cleaned_path).resolve() + + if not target_file.is_relative_to(self.base_path): + return False + + 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_path = file_path_or_url.replace("\\", "/").strip() + if self.url_prefix and cleaned_path.startswith(self.url_prefix): + return cleaned_path + + trimmed = cleaned_path.lstrip("/") + return f"{self.url_prefix}/{trimmed}" if self.url_prefix else f"/{trimmed}" \ No newline at end of file From d572cf022627178a07718ea2b0857ae0af4d6513 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Tue, 8 Sep 2026 09:50:35 +0330 Subject: [PATCH 70/82] feat(storage): add _resolve_path helper for robust path resolution - Added _resolve_path method to handle web URL and file path resolution within sandbox - Supports absolute, relative, and URL-prefixed paths - Returns resolved StdPath if within base_path boundaries, None otherwise - Used by delete, exists, and get_url methods for consistent path handling --- src/zcore/storage/local.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/zcore/storage/local.py b/src/zcore/storage/local.py index 1c84c73..a97b501 100644 --- a/src/zcore/storage/local.py +++ b/src/zcore/storage/local.py @@ -50,6 +50,44 @@ def __init__( self.validators = validators or [] self.base_path.mkdir(parents=True, exist_ok=True) + def _resolve_path(self, file_path_or_url: str) -> StdPath | None: + """Resolve a physical filesystem path from a web URL or file path within sandbox boundaries. + + Args: + file_path_or_url: Web URL or relative/absolute path to resolve. + + Returns: + Resolved absolute StdPath if within base_path boundaries, or None if outside. + """ + if not file_path_or_url: + return None + + cleaned_str = file_path_or_url.replace("\\", "/").strip() + candidate = StdPath(cleaned_str) + + try: + if candidate.is_absolute(): + resolved = candidate.resolve() + if resolved.is_relative_to(self.base_path): + return resolved + + direct_resolved = (StdPath.cwd() / candidate).resolve() + if direct_resolved.is_relative_to(self.base_path): + return direct_resolved + + if self.url_prefix and cleaned_str.startswith(self.url_prefix): + cleaned_str = cleaned_str[len(self.url_prefix) :].lstrip("/") + elif cleaned_str.startswith("/"): + cleaned_str = cleaned_str.lstrip("/") + + target_file = (self.base_path / cleaned_str).resolve() + if target_file.is_relative_to(self.base_path): + return target_file + + return None + except Exception: + return None + async def _resolve_destination(self, filename: str, folder: str = "") -> tuple[StdPath, str]: """Generate verified physical filesystem target paths and corresponding web URL representations. From 1599b3dfafadbe40bae95af66a4e69591a572372 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Tue, 8 Sep 2026 09:51:19 +0330 Subject: [PATCH 71/82] refactor(storage): use _resolve_path in delete method - Replaced manual path cleaning logic with _resolve_path helper - Simplified file deletion with missing_ok=True - Improved error handling and logging --- src/zcore/storage/local.py | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/src/zcore/storage/local.py b/src/zcore/storage/local.py index a97b501..b329acd 100644 --- a/src/zcore/storage/local.py +++ b/src/zcore/storage/local.py @@ -194,26 +194,17 @@ async def delete(self, file_path_or_url: str) -> bool: if not file_path_or_url: return False - try: - cleaned_path = file_path_or_url.replace("\\", "/").strip() - if self.url_prefix and cleaned_path.startswith(self.url_prefix): - cleaned_path = cleaned_path[len(self.url_prefix):].lstrip("/") - elif cleaned_path.startswith("/"): - cleaned_path = cleaned_path.lstrip("/") - - target_file = (self.base_path / cleaned_path).resolve() - - if not target_file.is_relative_to(self.base_path): - logger.warning( - f"Prevented arbitrary file deletion attempt outside base path: {file_path_or_url}" - ) - 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 + try: path_obj = Path(str(target_file)) - if await path_obj.exists(): - await path_obj.unlink() - return True - return False + await path_obj.unlink(missing_ok=True) + return True except Exception as e: logger.error(f"Failed to delete file '{file_path_or_url}': {e}") return False From 4f203c068f367cbbf1c22012a30121a54ec05598 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Tue, 8 Sep 2026 09:52:02 +0330 Subject: [PATCH 72/82] refactor(storage): use _resolve_path in exists method - Replaced manual path cleaning logic with _resolve_path helper - Simplified existence check with consistent path resolution --- src/zcore/storage/local.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/zcore/storage/local.py b/src/zcore/storage/local.py index b329acd..0452ccb 100644 --- a/src/zcore/storage/local.py +++ b/src/zcore/storage/local.py @@ -221,18 +221,11 @@ async def exists(self, file_path_or_url: str) -> bool: if not file_path_or_url: return False - try: - cleaned_path = file_path_or_url.replace("\\", "/").strip() - if self.url_prefix and cleaned_path.startswith(self.url_prefix): - cleaned_path = cleaned_path[len(self.url_prefix):].lstrip("/") - elif cleaned_path.startswith("/"): - cleaned_path = cleaned_path.lstrip("/") - - target_file = (self.base_path / cleaned_path).resolve() - - if not target_file.is_relative_to(self.base_path): - 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: From 06a9ce6f6e584be6b0483f2ca82c99cd47fc87e2 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Tue, 8 Sep 2026 09:52:39 +0330 Subject: [PATCH 73/82] refactor(storage): use _resolve_path in get_url for reliable URL generation - Replaced manual path cleaning with _resolve_path to resolve physical path - Generates URL from resolved relative path within base_path - Falls back to trimmed path when resolution fails --- src/zcore/storage/local.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/zcore/storage/local.py b/src/zcore/storage/local.py index 0452ccb..642f8f4 100644 --- a/src/zcore/storage/local.py +++ b/src/zcore/storage/local.py @@ -246,6 +246,11 @@ async def get_url(self, file_path_or_url: str) -> str: cleaned_path = file_path_or_url.replace("\\", "/").strip() if self.url_prefix and cleaned_path.startswith(self.url_prefix): return cleaned_path - + + target_file = self._resolve_path(file_path_or_url) + if target_file is not None and target_file.is_relative_to(self.base_path): + relative_part = target_file.relative_to(self.base_path).as_posix() + return f"{self.url_prefix}/{relative_part}" if self.url_prefix else f"/{relative_part}" + trimmed = cleaned_path.lstrip("/") return f"{self.url_prefix}/{trimmed}" if self.url_prefix else f"/{trimmed}" \ No newline at end of file From 2661209005c54fb7d389b0c60af5454a1265908e Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Tue, 8 Sep 2026 09:59:19 +0330 Subject: [PATCH 74/82] refactor(storage): extract key-based storage operations in LocalStorageProvider - Added _extract_key() to normalize web URLs, storage keys, and filesystem paths - Added _key_to_path() to safely resolve storage keys to absolute paths within sandbox - Simplified _resolve_path() to use _extract_key + _key_to_path - Refactored _resolve_destination() to use _key_to_path for path validation - Updated get_url() to use _extract_key for consistent URL generation - Improved path traversal protection with explicit base_path boundary checks --- src/zcore/storage/local.py | 100 +++++++++++++++++++++---------------- 1 file changed, 58 insertions(+), 42 deletions(-) diff --git a/src/zcore/storage/local.py b/src/zcore/storage/local.py index 642f8f4..951f524 100644 --- a/src/zcore/storage/local.py +++ b/src/zcore/storage/local.py @@ -50,44 +50,65 @@ def __init__( self.validators = validators or [] self.base_path.mkdir(parents=True, exist_ok=True) - def _resolve_path(self, file_path_or_url: str) -> StdPath | None: - """Resolve a physical filesystem path from a web URL or file path within sandbox boundaries. + 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 or relative/absolute path to resolve. + file_path_or_url: Web URL, storage key, or filesystem path. Returns: - Resolved absolute StdPath if within base_path boundaries, or None if outside. + Normalized POSIX storage key, or None if an explicit path attempts sandbox traversal. """ - if not file_path_or_url: - return None + cleaned = file_path_or_url.replace("\\", "/").strip() - cleaned_str = file_path_or_url.replace("\\", "/").strip() - candidate = StdPath(cleaned_str) + if self.url_prefix and cleaned.startswith(self.url_prefix): + return cleaned[len(self.url_prefix) :].lstrip("/") try: - if candidate.is_absolute(): - resolved = candidate.resolve() - if resolved.is_relative_to(self.base_path): - return resolved + 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 - direct_resolved = (StdPath.cwd() / candidate).resolve() - if direct_resolved.is_relative_to(self.base_path): - return direct_resolved + return cleaned.lstrip("/") - if self.url_prefix and cleaned_str.startswith(self.url_prefix): - cleaned_str = cleaned_str[len(self.url_prefix) :].lstrip("/") - elif cleaned_str.startswith("/"): - cleaned_str = cleaned_str.lstrip("/") + def _key_to_path(self, key: str | None) -> StdPath | None: + """Safely resolve a storage key to an absolute filesystem path within sandbox boundaries. - target_file = (self.base_path / cleaned_str).resolve() - if target_file.is_relative_to(self.base_path): - return target_file + Args: + key: Normalized storage key. + 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. + + 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. @@ -102,19 +123,16 @@ async def _resolve_destination(self, filename: str, folder: str = "") -> tuple[S AppException: If a directory traversal attempt is detected. """ normalized_folder = folder.strip("/\\").replace("\\", "/") - target_dir = (self.base_path / normalized_folder).resolve() - - if not target_dir.is_relative_to(self.base_path): - raise AppException("Path traversal attempt detected") - - await Path(str(target_dir)).mkdir(parents=True, exist_ok=True) - 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 - physical_path = target_dir / secure_filename - relative_web_path = f"{normalized_folder}/{secure_filename}" if normalized_folder else secure_filename - web_url = f"{self.url_prefix}/{relative_web_path}" if self.url_prefix else f"/{relative_web_path}" + physical_path = self._key_to_path(storage_key) + if physical_path is None: + raise AppException("Path traversal attempt detected") + + 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}" return physical_path, web_url @@ -189,7 +207,7 @@ async def delete(self, file_path_or_url: str) -> bool: 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 target is absent. + True if unlinking succeeds, False if traversal is detected or deletion fails. """ if not file_path_or_url: return False @@ -243,14 +261,12 @@ async def get_url(self, file_path_or_url: str) -> str: if not file_path_or_url: return "" - cleaned_path = file_path_or_url.replace("\\", "/").strip() - if self.url_prefix and cleaned_path.startswith(self.url_prefix): - return cleaned_path + cleaned = file_path_or_url.replace("\\", "/").strip() + if self.url_prefix and cleaned.startswith(self.url_prefix): + return cleaned - target_file = self._resolve_path(file_path_or_url) - if target_file is not None and target_file.is_relative_to(self.base_path): - relative_part = target_file.relative_to(self.base_path).as_posix() - return f"{self.url_prefix}/{relative_part}" if self.url_prefix else f"/{relative_part}" + key = self._extract_key(file_path_or_url) + if not key: + return "" - trimmed = cleaned_path.lstrip("/") - return f"{self.url_prefix}/{trimmed}" if self.url_prefix else f"/{trimmed}" \ No newline at end of file + return f"{self.url_prefix}/{key}" if self.url_prefix else f"/{key}" \ No newline at end of file From 8a44de39337c97e3bd4a761958a7043696811794 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Tue, 8 Sep 2026 10:03:38 +0330 Subject: [PATCH 75/82] test(storage): add comprehensive tests for storage provider operations - Added docstrings to all test functions - Added test_storage_get_url_and_exists_variants for URL/key/path resolution - Updated tests to use new URL-based upload return values - Replaced os.path.exists with provider.exists() for consistent verification - Added physical path resolution checks using provider._resolve_path() - Expanded path traversal prevention test with deletion attempts --- tests/test_storage.py | 136 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 110 insertions(+), 26 deletions(-) diff --git a/tests/test_storage.py b/tests/test_storage.py index 1ac662e..1c0e5ee 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -18,6 +18,7 @@ def create_mock_upload_file(content: bytes, filename: str, size: int | None = None) -> UploadFile: + """Helper creating an in-memory UploadFile instance for testing.""" file_obj = BytesIO(content) upload_file = UploadFile(file=file_obj, filename=filename) if size is not None: @@ -27,6 +28,7 @@ def create_mock_upload_file(content: bytes, filename: str, size: int | None = No delattr(upload_file, "size") return upload_file + @pytest.mark.anyio @pytest.mark.parametrize( "malicious_folder", @@ -34,12 +36,13 @@ def create_mock_upload_file(content: bytes, filename: str, size: int | None = No "../", "../../etc", "..\\..\\", - ] + ], ) async def test_storage_path_traversal_prevention(test_storage_dir: str, malicious_folder: str) -> None: + """Ensure path traversal attempts are blocked during upload and deletion.""" provider = LocalStorageProvider(base_path=test_storage_dir) file = create_mock_upload_file(b"test data", "malicious.txt") - + with pytest.raises(AppException) as exc_info: await provider.upload(file, malicious_folder) assert "Path traversal attempt detected" in str(exc_info.value) @@ -54,6 +57,7 @@ async def fake_stream() -> AsyncGenerator[bytes, None]: delete_success = await provider.delete(f"{test_storage_dir}/{malicious_folder}/target.txt") assert delete_success is False + @pytest.mark.parametrize( "content, filename, allowed_mimes, should_pass, error_message", [ @@ -63,18 +67,19 @@ async def fake_stream() -> AsyncGenerator[bytes, None]: (b"", "evil.jpg", ["image/jpeg"], False, "Security policy violation"), (b"MZ\x90\x00\x03\x00\x00\x00", "payload.jpg", ["image/jpeg"], False, "Security policy violation"), (b"#!/bin/sh\nrm -rf /", "exploit.jpg", ["image/jpeg"], False, "Security policy violation"), - ] + ], ) def test_validator_mime_magic_bytes( content: bytes, filename: str, allowed_mimes: list[str], should_pass: bool, - error_message: str + error_message: str, ) -> None: + """Verify MIME type and active content detection via magic bytes.""" validator = SafeMimeTypeValidator(allowed_mimes=allowed_mimes) file = create_mock_upload_file(content, filename) - + if should_pass: validator(file) else: @@ -82,6 +87,7 @@ def test_validator_mime_magic_bytes( validator(file) assert error_message in str(exc_info.value) + @pytest.mark.parametrize( "max_size_mb, size_property, content_len, should_pass", [ @@ -89,19 +95,20 @@ def test_validator_mime_magic_bytes( (1.0, 2 * 1024 * 1024, b"dummy", False), (1.0, None, 500 * 1024, True), (1.0, None, 2 * 1024 * 1024, False), - ] + ], ) def test_validator_max_file_size( max_size_mb: float, size_property: int | None, content_len: int, - should_pass: bool + should_pass: bool, ) -> None: + """Verify maximum file size constraints.""" validator = MaxFileSizeValidator(max_size_mb=max_size_mb) content = b"x" * content_len if size_property is None else b"short" - + file = create_mock_upload_file(content, "test.bin", size=size_property) - + if should_pass: validator(file) else: @@ -109,62 +116,120 @@ def test_validator_max_file_size( validator(file) assert "exceeds the limit" in str(exc_info.value) + @pytest.mark.anyio async def test_storage_successful_upload(test_storage_dir: str) -> None: + """Verify successful file upload, URL generation, and content persistence.""" provider = LocalStorageProvider(base_path=test_storage_dir) file = create_mock_upload_file(b"test data content", "hello.txt") - path = await provider.upload(file, "text_files") - assert os.path.exists(path) - with open(path, "rb") as f: + url = await provider.upload(file, "text_files") + + assert url.startswith("/storage/text_files/") + assert await provider.exists(url) is True + + physical_path = provider._resolve_path(url) + assert physical_path is not None + assert os.path.exists(physical_path) + with open(physical_path, "rb") as f: assert f.read() == b"test data content" + @pytest.mark.anyio async def test_storage_successful_upload_stream(test_storage_dir: str) -> None: + """Verify successful binary streaming upload.""" provider = LocalStorageProvider(base_path=test_storage_dir) + async def fake_stream() -> AsyncGenerator[bytes, None]: yield b"chunk_one_" yield b"chunk_two" - path = await provider.upload_stream(fake_stream(), "streamed.bin", "streams") - assert os.path.exists(path) - with open(path, "rb") as f: + + url = await provider.upload_stream(fake_stream(), "streamed.bin", "streams") + + assert url.startswith("/storage/streams/") + assert await provider.exists(url) is True + + physical_path = provider._resolve_path(url) + assert physical_path is not None + assert os.path.exists(physical_path) + with open(physical_path, "rb") as f: assert f.read() == b"chunk_one_chunk_two" + @pytest.mark.anyio async def test_storage_collision_prevention(test_storage_dir: str) -> None: + """Verify collision prevention generates unique keys for identically named files.""" provider = LocalStorageProvider(base_path=test_storage_dir) f1 = create_mock_upload_file(b"content 1", "test.txt") f2 = create_mock_upload_file(b"content 2", "test.txt") - p1 = await provider.upload(f1, "collision") - p2 = await provider.upload(f2, "collision") - assert p1 != p2 - assert os.path.exists(p1) - assert os.path.exists(p2) + + u1 = await provider.upload(f1, "collision") + u2 = await provider.upload(f2, "collision") + + assert u1 != u2 + assert await provider.exists(u1) is True + assert await provider.exists(u2) is True + @pytest.mark.anyio async def test_storage_auto_directory_creation(test_storage_dir: str) -> None: + """Verify automatic creation of nested directory structures upon upload.""" provider = LocalStorageProvider(base_path=test_storage_dir) f = create_mock_upload_file(b"data", "test.txt") - path = await provider.upload(f, "new/nested/folder") - assert os.path.exists(path) + url = await provider.upload(f, "new/nested/folder") + + assert await provider.exists(url) is True + physical_path = provider._resolve_path(url) + assert physical_path is not None + assert os.path.exists(physical_path) + @pytest.mark.anyio async def test_storage_successful_delete(test_storage_dir: str) -> None: + """Verify file deletion and corresponding existence checks.""" provider = LocalStorageProvider(base_path=test_storage_dir) f = create_mock_upload_file(b"data", "test.txt") - path = await provider.upload(f, "docs") - assert os.path.exists(path) - res = await provider.delete(path) + url = await provider.upload(f, "docs") + + assert await provider.exists(url) is True + res = await provider.delete(url) assert res is True - assert not os.path.exists(path) + assert await provider.exists(url) is False + @pytest.mark.anyio async def test_storage_delete_non_existent(test_storage_dir: str) -> None: + """Verify idempotent deletion of non-existent files inside storage boundary.""" provider = LocalStorageProvider(base_path=test_storage_dir) fake_path = os.path.join(test_storage_dir, "docs", "missing.txt") res = await provider.delete(fake_path) assert res is True + +@pytest.mark.anyio +async def test_storage_get_url_and_exists_variants(test_storage_dir: str) -> None: + """Verify URL resolution and existence checks across URL, key, and filesystem formats.""" + provider = LocalStorageProvider(base_path=test_storage_dir, url_prefix="/media") + file = create_mock_upload_file(b"custom url test", "doc.txt") + url = await provider.upload(file, "records") + + assert url.startswith("/media/records/") + assert await provider.exists(url) is True + + key = provider._extract_key(url) + assert key is not None + assert await provider.exists(key) is True + assert await provider.get_url(key) == url + + physical_path = str(provider._key_to_path(key)) + assert await provider.exists(physical_path) is True + assert await provider.get_url(physical_path) == url + + assert await provider.exists("") is False + assert await provider.get_url("") == "" + + def test_validator_file_extension_case_insensitive() -> None: + """Verify case-insensitive matching for allowed file extensions.""" validator = FileExtensionValidator(allowed_extensions=["png", "JPEG"]) f1 = create_mock_upload_file(b"", "image.PNG") f2 = create_mock_upload_file(b"", "photo.jpeg") @@ -173,19 +238,25 @@ def test_validator_file_extension_case_insensitive() -> None: validator(f2) validator(f3) + def test_validator_file_extension_blocked() -> None: + """Verify disallowed extensions trigger a validation error.""" validator = FileExtensionValidator(allowed_extensions=["pdf"]) f = create_mock_upload_file(b"", "script.py") with pytest.raises(ValidationError): validator(f) + def test_validator_file_extension_missing() -> None: + """Verify missing file extensions trigger a validation error.""" validator = FileExtensionValidator(allowed_extensions=["pdf"]) f = create_mock_upload_file(b"", "config") with pytest.raises(ValidationError): validator(f) + def test_validator_file_extension_double_extension_attack() -> None: + """Verify trailing executable extensions in double-extension filenames are blocked.""" validator = FileExtensionValidator(allowed_extensions=["pdf", "png"]) f1 = create_mock_upload_file(b"", "doc.pdf.exe") f2 = create_mock_upload_file(b"", "avatar.png.php") @@ -194,38 +265,51 @@ def test_validator_file_extension_double_extension_attack() -> None: with pytest.raises(ValidationError): validator(f2) + def test_validator_mime_fallback() -> None: + """Verify fallback to standard mimetype guessing for safe text-based payloads.""" validator = SafeMimeTypeValidator(allowed_mimes=["application/json", "text/plain"]) f1 = create_mock_upload_file(b'{"key": "value"}', "data.json") f2 = create_mock_upload_file(b"plain text", "note.txt") validator(f1) validator(f2) + def test_validator_mime_read_error() -> None: + """Verify hardware read errors during inspection raise a ValidationError.""" validator = SafeMimeTypeValidator(allowed_mimes=["image/png"]) + class ErrorFile: def read(self, *args, **kwargs): raise OSError("Hardware read error") + def seek(self, *args, **kwargs): pass + upload_file = UploadFile(file=ErrorFile(), filename="test.png") with pytest.raises(ValidationError) as exc_info: validator(upload_file) assert "Failed to validate file signatures." in str(exc_info.value) + @pytest.mark.anyio async def test_storage_os_permission_error(test_storage_dir: str, monkeypatch: pytest.MonkeyPatch) -> None: + """Verify underlying OS write errors are cleanly caught and wrapped in AppException.""" provider = LocalStorageProvider(base_path=test_storage_dir) file = create_mock_upload_file(b"test data", "permission.txt") + def mock_open(*args, **kwargs): raise OSError("Mock disk error") + monkeypatch.setattr(aiofiles, "open", mock_open) with pytest.raises(AppException) as exc_info: await provider.upload(file, "uploads") assert "Error saving file" in str(exc_info.value) + @pytest.mark.anyio async def test_storage_provider_dependency(test_storage_dir: str) -> None: + """Verify resolution of the StorageProvider dependency injection stub.""" provider_instance = LocalStorageProvider(base_path=test_storage_dir) container.register_singleton(StorageProvider, provider_instance) resolved = await get_storage_provider(provider_instance) From aeab7b084b4ec423df8df5b4709abefa1b3a27fb Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Tue, 8 Sep 2026 09:37:20 +0330 Subject: [PATCH 76/82] refactor(repository): reorder parameters in get_list for better API consistency - Moved *criterion before pagination to match other method signatures - Ensures positional filters are placed before optional pagination params --- src/zcore/db/repository.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/zcore/db/repository.py b/src/zcore/db/repository.py index bf77e98..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: From 57a41d546ca74171fca3a799d9e45a8e08103f00 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Tue, 8 Sep 2026 09:38:28 +0330 Subject: [PATCH 77/82] refactor(service): reorder parameters in get_list to match repository - Moved *criterion before pagination to align with repository.get_list signature - Updated repository call to use keyword arguments for clarity - Added docstring with parameter descriptions --- src/zcore/service/base.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/zcore/service/base.py b/src/zcore/service/base.py index 9593b37..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) From b4f7cb17b3e4d21aa41f23d99a209cd0bb7730bd Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Tue, 8 Sep 2026 09:39:39 +0330 Subject: [PATCH 78/82] refactor(router): use explicit keyword argument in get_list call - Changed service.get_list(pagination) to service.get_list(pagination=pagination) - Ensures compatibility with updated get_list signature where *criterion precedes pagination --- src/zcore/web/base_router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zcore/web/base_router.py b/src/zcore/web/base_router.py index d27a9f5..a7ed92b 100644 --- a/src/zcore/web/base_router.py +++ b/src/zcore/web/base_router.py @@ -483,7 +483,7 @@ async def get_all_endpoint( Returns: The list of resolved model records wrapped in a ResponseWrapper. """ - result = await service.get_list(pagination) + result = await service.get_list(pagination=pagination) from zcore.db.pagination import PaginatedResult if isinstance(result, PaginatedResult): From 236e1abd7cab289cbd43537c900acc224046e6d9 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Wed, 16 Sep 2026 10:22:57 +0330 Subject: [PATCH 79/82] chore(release): bump version to 0.1.0-rc.1 --- README.md | 2 +- src/zcore/cli/__init__.py | 2 +- src/zcore/cli/templates.py | 2 +- src/zcore/cli/ui.py | 2 +- tests/test_cli.py | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7dee5cb..a866f61 100644 --- a/README.md +++ b/README.md @@ -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.9 • 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 diff --git a/src/zcore/cli/__init__.py b/src/zcore/cli/__init__.py index 5e22325..fce0d4a 100644 --- a/src/zcore/cli/__init__.py +++ b/src/zcore/cli/__init__.py @@ -246,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.9[/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 97455de..35bc6fd 100644 --- a/src/zcore/cli/templates.py +++ b/src/zcore/cli/templates.py @@ -34,7 +34,7 @@ async def root(): return { "status": "healthy", "framework": "ZCore", - "version": "0.1.0-beta.9", + "version": "0.1.0-rc.1", "debug": settings.DEBUG } """ diff --git a/src/zcore/cli/ui.py b/src/zcore/cli/ui.py index ea595c5..48f5e81 100644 --- a/src/zcore/cli/ui.py +++ b/src/zcore/cli/ui.py @@ -27,7 +27,7 @@ def print_banner() -> None: console.print() console.print( f"[bold {ZCORE_PRIMARY}]⚡ ZCore Framework[/bold {ZCORE_PRIMARY}] " - f"[dim white]v0.1.0-beta.9[/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}]") diff --git a/tests/test_cli.py b/tests/test_cli.py index 66349ba..3fb952a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -276,11 +276,11 @@ def test_cli_version_output_beta_9() -> None: with pytest.raises(SystemExit) as exc_info: main() assert exc_info.value.code == 0 - assert "0.1.0-beta.9" in capture.get() + assert "0.1.0-rc.1" in capture.get() with console.capture() as capture: print_banner() - assert "0.1.0-beta.9" in capture.get() + assert "0.1.0-rc.1" in capture.get() def test_cli_scaffold_templates_use_database_settings_config(run_in_tmp_path: Path) -> None: From db10dae9330799141673ffdf55f3cb8e0d3eb79a Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Thu, 17 Sep 2026 11:57:25 +0330 Subject: [PATCH 80/82] fix(storage): use raw byte string literals for regex patterns - Changed regex pattern literals from b'...' to rb'...' to avoid escape sequence warnings - Ensures proper regex compilation for byte patterns containing backslashes --- src/zcore/storage/validators.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/zcore/storage/validators.py b/src/zcore/storage/validators.py index c949c6d..fbf869a 100644 --- a/src/zcore/storage/validators.py +++ b/src/zcore/storage/validators.py @@ -32,20 +32,20 @@ ) MALICIOUS_CONTENT_PATTERNS: tuple[re.Pattern[bytes], ...] = ( - re.compile(b"<\?php", re.IGNORECASE), - re.compile(b"]", re.IGNORECASE), - re.compile(b"javascript:", re.IGNORECASE), - re.compile(b"vbscript:", re.IGNORECASE), - re.compile(b"data:text/html", re.IGNORECASE), - re.compile(b"]", re.IGNORECASE), - re.compile(b"]", re.IGNORECASE), - re.compile(b"]", re.IGNORECASE), - re.compile(b"]", re.IGNORECASE), - re.compile(b"onload\s*=", re.IGNORECASE), - re.compile(b"onerror\s*=", re.IGNORECASE), - re.compile(b"onclick\s*=", re.IGNORECASE), - re.compile(b"xlink:href\s*=\s*[\"']javascript:", re.IGNORECASE), + re.compile(rb"<\?php", re.IGNORECASE), + re.compile(rb"]", re.IGNORECASE), + re.compile(rb"javascript:", re.IGNORECASE), + re.compile(rb"vbscript:", re.IGNORECASE), + re.compile(rb"data:text/html", re.IGNORECASE), + re.compile(rb"]", re.IGNORECASE), + re.compile(rb"]", re.IGNORECASE), + re.compile(rb"]", re.IGNORECASE), + re.compile(rb"]", re.IGNORECASE), + re.compile(rb"onload\s*=", re.IGNORECASE), + re.compile(rb"onerror\s*=", re.IGNORECASE), + re.compile(rb"onclick\s*=", re.IGNORECASE), + re.compile(rb"xlink:href\s*=\s*[\"']javascript:", re.IGNORECASE), ) From ae7ad6aca758173dad62daf4f3efd2e97653e62c Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Fri, 18 Sep 2026 04:57:14 -0700 Subject: [PATCH 81/82] docs(readme): migrate documentation links to custom domain - Updated all docs links from baseryn.github.io/zcore-docs to zcore.baseryn.com - Updated Documentation URL in pyproject.toml - Removed email from Ali Alf Ostovar author entry - Added support email to Baseryn maintainer entry --- README.md | 16 ++++++++-------- pyproject.toml | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index a866f61..815f31c 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ CI - + Documentation @@ -103,7 +103,7 @@ zc run Your API is live at **`http://127.0.0.1:8000`** with 7 secure endpoints (CRUD + Dynamic Search) ready. -> 📖 **Full walkthrough:** [Quick Start Guide](https://baseryn.github.io/zcore-docs/docs/quick-start) +> 📖 **Full walkthrough:** [Quick Start Guide](https://zcore.baseryn.com/docs/quick-start) --- @@ -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 0e52628..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", @@ -87,7 +87,7 @@ all = [ [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] From 1ff4d4d426d0f6de20f1cb2c36e6197719eedf2c Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Fri, 18 Sep 2026 07:39:27 -0700 Subject: [PATCH 82/82] docs(readme): fix optional field typing in Zchema example - Updated salary field in TaskResponse to Optional with None default --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 815f31c..9df5557 100644 --- a/README.md +++ b/README.md @@ -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.