Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
132f354
fix(di): skip auto-wiring unregistered constructor parameters with de…
dwaddle Oct 17, 2026
046489e
fix(di): track resolved BaseSettings in singletons and guard scoped d…
dwaddle Oct 18, 2026
3c35710
fix(di): skip auto-wiring unregistered constructor parameters with de…
alialfostovar Oct 18, 2026
a439cfe
feat(di): support Optional/Union unwrapping and keyword-based auto-wi…
alialfostovar Oct 18, 2026
d079c3c
feat(di): preserve list-compatible signature cache with _DependencySi…
alialfostovar Oct 18, 2026
29ee3c4
fix(di): support constructor default parameters, kwargs auto-wiring, …
alialfostovar Oct 18, 2026
bb1ad50
feat(pagination): support runtime cursor field and order overrides
alialfostovar Oct 18, 2026
7cfcb90
test(pagination,router): add cursor pagination with dynamic sort fiel…
alialfostovar Oct 18, 2026
f216621
fix(pagination): align cursor pagination keyset evaluation with dynam…
alialfostovar Oct 18, 2026
1339e19
fix(pagination): coerce date columns using SQLAlchemy Date type
alialfostovar Oct 18, 2026
c06cd91
style: reorder imports and format pagination imports
alialfostovar Oct 18, 2026
a969910
fix(pagination): coerce cursor values for SQLAlchemy Date columns (#137)
alialfostovar Oct 18, 2026
bab346c
feat(repository): add composite primary key support
alialfostovar Oct 18, 2026
4944281
style: apply formatting cleanup and explicit zip strictness
alialfostovar Oct 18, 2026
13654f1
feat(repository): add composite primary key support
alialfostovar Oct 18, 2026
09ba9f2
style: apply formatting cleanup and explicit zip strictness
alialfostovar Oct 18, 2026
e30711d
Merge branch 'feat/repository-composite-primary-key-support' of githu…
alialfostovar Oct 18, 2026
943e40b
feat(repository): support composite primary keys and partition prunin…
alialfostovar Oct 18, 2026
8fd9624
feat(router): auto-sort routes on registration and add custom route hook
alialfostovar Oct 18, 2026
63a1649
test(web): add custom route ordering and hook tests for subclassed ro…
alialfostovar Oct 18, 2026
4ca8c0f
fix(router): auto-sort custom routes on registration to prevent path …
alialfostovar Oct 18, 2026
b8c6cf5
feat(search): reject conflicting cursor and page pagination params
alialfostovar Oct 19, 2026
cf42448
feat(search): reject conflicting cursor and page pagination parameter…
alialfostovar Oct 19, 2026
3afaaca
fix(cache): add thread lock to prevent duplicate eviction task creation
alialfostovar Oct 19, 2026
7820bb6
fix(cache): thread-safe background eviction task scheduling (#141)
alialfostovar Oct 19, 2026
67f7be8
feat(cli): derive version from package metadata and bump to rc.3
alialfostovar Oct 19, 2026
df738bb
feat(repository): enforce composite primary key validation and lazy c…
alialfostovar Oct 19, 2026
55aacdf
refactor: hardcode static __version__ and fix repository template
alialfostovar Oct 19, 2026
6b3d865
chore(release): bump version to v0.1.0-rc.3 and enforce strict compos…
alialfostovar Oct 19, 2026
0cc541f
docs(readme): refine README with technical, neutral tone
alialfostovar Oct 19, 2026
ba89fc3
fix(testing): preserve pre-existing dependency overrides in Dependenc…
alialfostovar Oct 19, 2026
c50a1c9
fix(cache): synchronize _ensure_eviction_task execution with threadin…
dwaddle Oct 20, 2026
aca9bc1
feat(router): replace ?schema=true with dedicated /_schema introspect…
alialfostovar Oct 20, 2026
7481a59
Merge branch 'release/v0.1.0-rc.3' into fix/cache-eviction-thread-safety
alialfostovar Oct 20, 2026
055923e
fix(cache): synchronize _ensure_eviction_task execution with threadin…
alialfostovar Oct 20, 2026
e6c0463
style(cache): collapse eviction task creation and remove unused import
alialfostovar Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 118 additions & 109 deletions README.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions src/zcore/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""ZCore Framework Core Package Entrypoint."""

__version__ = "0.1.0-rc.3"

from typing import TYPE_CHECKING, Any

from zcore.config import settings
Expand Down Expand Up @@ -113,6 +115,7 @@
"ZCoreRequest",
"ZDateTime",
"Zchema",
"__version__",
"app_exception_handler",
"background_scope",
"background_task",
Expand Down
22 changes: 15 additions & 7 deletions src/zcore/cache/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import asyncio
import contextlib
import threading
from typing import Any, Generic, TypeVar

import structlog
Expand All @@ -29,6 +30,7 @@

_shared_redis_client: Any | None = None
_eviction_task: asyncio.Task | None = None
_eviction_lock = threading.Lock()


def _ensure_eviction_task(interval: int | None = None) -> None:
Expand All @@ -42,10 +44,13 @@ def _ensure_eviction_task(interval: int | None = None) -> None:
effective_interval: int = int(raw_interval) if raw_interval is not None else 60
try:
loop = asyncio.get_running_loop()
if _eviction_task is None or _eviction_task.done():
_eviction_task = loop.create_task(_start_eviction_loop(interval=effective_interval))
except RuntimeError:
pass
return

if _eviction_task is None or _eviction_task.done():
with _eviction_lock:
if _eviction_task is None or _eviction_task.done():
_eviction_task = loop.create_task(_start_eviction_loop(interval=effective_interval))


def init_cache(redis_url: str | None = None, **kwargs: Any) -> None:
Expand Down Expand Up @@ -89,11 +94,14 @@ async def close_cache() -> None:
"""Cancel background loops and cleanly close distributed connections."""
global _shared_redis_client, _eviction_task

if _eviction_task and not _eviction_task.done():
_eviction_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await _eviction_task
with _eviction_lock:
task = _eviction_task
_eviction_task = None

if task and not task.done():
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
logger.debug("Cache memory eviction loop stopped.")

if _shared_redis_client:
Expand Down
3 changes: 2 additions & 1 deletion src/zcore/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import questionary
from questionary import Choice

from zcore import __version__
from zcore.cli.commands import (
DB_DRIVERS,
find_zcore_projects,
Expand Down Expand Up @@ -366,7 +367,7 @@ def main() -> None:

if args.version:
console.print(
f"[bold {ZCORE_PRIMARY}]ZCore Framework[/bold {ZCORE_PRIMARY}] - Version [bold white]0.1.0-rc.2[/bold white]"
f"[bold {ZCORE_PRIMARY}]ZCore Framework[/bold {ZCORE_PRIMARY}] - Version [bold white]{__version__}[/bold white]"
)
sys.exit(0)

Expand Down
11 changes: 9 additions & 2 deletions src/zcore/cli/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@
sys.path.insert(0, str(Path(__file__).resolve().parent))

from fastapi import FastAPI
from zcore import Kernel, db_manager, register_db_event_dispatcher, register_exception_handlers, settings
from zcore import (
Kernel,
__version__,
db_manager,
register_db_event_dispatcher,
register_exception_handlers,
settings,
)
from zcore.logging import setup_logging
from zcore.web import RequestLogMiddleware, ScopedDependencyMiddleware

Expand Down Expand Up @@ -33,7 +40,7 @@ async def root():
return {
"status": "healthy",
"framework": "ZCore",
"version": "0.1.0-rc.2",
"version": __version__,
"debug": settings.DEBUG
}
"""
Expand Down
4 changes: 3 additions & 1 deletion src/zcore/cli/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from questionary import Style
from rich.console import Console

from zcore import __version__

console = Console()

ZCORE_PRIMARY = "#10b981"
Expand All @@ -29,7 +31,7 @@ def print_banner() -> None:
console.print()
console.print(
f"[bold {ZCORE_PRIMARY}]⚡ ZCore Framework[/bold {ZCORE_PRIMARY}] "
f"[dim white]v0.1.0-rc.2[/dim white] "
f"[dim white]v{__version__}[/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}]")
Expand Down
4 changes: 3 additions & 1 deletion src/zcore/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from pydantic import AliasChoices, BaseModel, Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

from zcore.kernel.di import container
from zcore.kernel.di import DIException, container

T = TypeVar("T", bound="Settings")

Expand Down Expand Up @@ -174,6 +174,8 @@ def get_settings(settings_class: type[T] = Settings) -> T: # type: ignore[assig
The resolved settings instance of type `T`.
"""
try:
if not container.is_registered(settings_class):
raise DIException(f"Settings class {settings_class.__name__} is not initialized.")
return cast(T, container.resolve(settings_class))
except Exception:
settings_inst = settings_class()
Expand Down
48 changes: 39 additions & 9 deletions src/zcore/db/pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@
import math
import uuid
from collections.abc import Sequence
from datetime import UTC, datetime
from datetime import UTC, date, datetime
from decimal import Decimal
from typing import Any, Generic, Literal, TypeVar

from pydantic import BaseModel, Field, model_validator
from sqlalchemy import and_, func, inspect, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import Select
from sqlalchemy.sql.sqltypes import DateTime
from sqlalchemy.sql.sqltypes import Date, DateTime

from zcore.config import settings
from zcore.exceptions.base import ValidationError
Expand Down Expand Up @@ -87,10 +88,14 @@ class CursorParams(BaseModel):
cursor: The base64-encoded keyset cursor string specifying position context.
If None, fetches from the beginning. Defaults to None.
size: The maximum quantity of records to yield in the dataset.
cursor_field: Target column name used for keyset boundary evaluation. Defaults to None.
order: Target sorting order applied during keyset boundary traversal. Defaults to None.
"""

cursor: str | None = None
size: int | None = None
cursor_field: str | None = None
order: Literal["asc", "desc"] | str | None = None

@model_validator(mode="after")
def _validate_and_bound_size(self) -> "CursorParams":
Expand Down Expand Up @@ -227,7 +232,7 @@ def __init__(self, cursor_field: str | None = None, order: str = "desc"):
order: The sorting order ('asc' or 'desc'). Defaults to "desc".
"""
self.cursor_field = cursor_field
self.order = order.lower()
self.order = order.lower() if order else "desc"

def _encode_cursor(self, last_item: Any, cursor_field: str, pk_name: str) -> str:
"""Serialize and encode an entity's coordinate parameters to base64.
Expand All @@ -244,6 +249,10 @@ def _encode_cursor(self, last_item: Any, cursor_field: str, pk_name: str) -> str
if isinstance(value, datetime):
value = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
value = value.isoformat()
elif isinstance(value, date):
value = value.isoformat()
elif isinstance(value, (uuid.UUID, Decimal)):
value = str(value)

payload = {"value": value, "pk": str(getattr(last_item, pk_name, ""))}

Expand Down Expand Up @@ -310,7 +319,9 @@ async def paginate(
mapper = inspect(model)
pk_col = mapper.primary_key[0]
pk_name = pk_col.name
effective_cursor_field = self.cursor_field or pk_name

effective_cursor_field = params.cursor_field or self.cursor_field or pk_name
effective_order = (params.order or self.order or "desc").lower()

valid_columns = {col.key for col in mapper.columns}
if effective_cursor_field not in valid_columns:
Expand All @@ -336,22 +347,41 @@ async def paginate(
with contextlib.suppress(ValueError):
last_pk = uuid.UUID(last_pk)

if isinstance(col.type, DateTime) and isinstance(val, str):
try:
col_type = getattr(col.type, "python_type", None)
except (NotImplementedError, AttributeError):
col_type = None

if (col_type is datetime or isinstance(col.type, DateTime)) and isinstance(val, str):
with contextlib.suppress(ValueError):
val = datetime.fromisoformat(val)
elif (col_type is date or isinstance(col.type, Date)) and isinstance(val, str):
with contextlib.suppress(ValueError):
val = date.fromisoformat(val.split("T")[0])
elif col_type is uuid.UUID and isinstance(val, str):
with contextlib.suppress(ValueError):
val = uuid.UUID(val)
elif col_type is int and isinstance(val, str):
with contextlib.suppress(ValueError):
val = int(val)
elif col_type is float and isinstance(val, str):
with contextlib.suppress(ValueError):
val = float(val)

if effective_cursor_field == pk_name:
query = query.where(col < val if self.order == "desc" else col > val)
query = query.where(col < val if effective_order == "desc" else col > val)
else:
if self.order == "desc":
if effective_order == "desc":
query = query.where(or_(col < val, and_(col == val, pk_col < last_pk)))
else:
query = query.where(or_(col > val, and_(col == val, pk_col > last_pk)))

query = query.order_by(None)

if effective_cursor_field == pk_name:
query = query.order_by(col.desc() if self.order == "desc" else col.asc())
query = query.order_by(col.desc() if effective_order == "desc" else col.asc())
else:
if self.order == "desc":
if effective_order == "desc":
query = query.order_by(col.desc(), pk_col.desc())
else:
query = query.order_by(col.asc(), pk_col.asc())
Expand Down
Loading
Loading