From 967dc97863c32c13c8daab38641cd24887188f78 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sun, 23 Aug 2026 16:42:29 +0330 Subject: [PATCH 01/19] perf(db): optimize create_multi using INSERT RETURNING clause Refactor WriteRepositoryMixin.create_multi to leverage SQLAlchemy 2.0 insert().values().returning() execution. - Eliminate N+1 SELECT queries caused by the sequential refresh() loop. - Execute bulk inserts in a single atomic database statement. - Retrieve and return created model instances with resolved IDs directly. --- src/zcore/db/repository.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/zcore/db/repository.py b/src/zcore/db/repository.py index e1c0a42..ec2524f 100644 --- a/src/zcore/db/repository.py +++ b/src/zcore/db/repository.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any, Generic, TypeVar from pydantic import BaseModel -from sqlalchemy import Select, func, inspect, select +from sqlalchemy import Select, func, inspect, select, insert from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import load_only from sqlalchemy.orm.interfaces import ExecutableOption @@ -258,13 +258,11 @@ async def create_multi( if not schemas: return [] - records = [self.model(**schema.model_dump()) for schema in schemas] - self.db.add_all(records) + 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() - if refresh: - for record in records: - await self.db.refresh(record) - return records + return list(result.scalars().all()) async def update( self, id: Any, schema: BaseModel, partial: bool = False, **extra_data: Any From ae3209d037125f8102d06ec1ca41e6f0c7e52d07 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sun, 23 Aug 2026 16:55:01 +0330 Subject: [PATCH 02/19] perf(db): optimize update_multi using UPDATE RETURNING clause Refactor WriteRepositoryMixin.update_multi to leverage SQLAlchemy 2.0 bulk update execution with the eturning() clause. - Eliminate initial SELECT query (get_by_ids) prior to updating records. - Remove Python-side in-memory setattr loops and subsequent refresh queries. - Execute batch updates with parameter lists via session.scalars(). --- src/zcore/db/repository.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/zcore/db/repository.py b/src/zcore/db/repository.py index ec2524f..7f5753c 100644 --- a/src/zcore/db/repository.py +++ b/src/zcore/db/repository.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any, Generic, TypeVar from pydantic import BaseModel -from sqlalchemy import Select, func, inspect, select, insert +from sqlalchemy import Select, func, inspect, select, insert, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import load_only from sqlalchemy.orm.interfaces import ExecutableOption @@ -307,21 +307,16 @@ async def update_multi( if not data: return [] - records = await self.get_by_ids(ids=list(data.keys())) - record_map = {getattr(r, self.pk_name): r for r in records} - updated_records = [] - for record_id, schema in data.items(): - record = record_map.get(record_id) - if record: - update_data = schema.model_dump(exclude_unset=partial) - for field, value in update_data.items(): - setattr(record, field, value) - updated_records.append(record) + payloads: list[dict[str, Any]] = [] + for pk_val, schema in data.items(): + dump = schema.model_dump(exclude_unset=partial) + dump[self.pk_name] = pk_val + payloads.append(dump) + + stmt = update(self.model).returning(self.model) + result = await self.db.scalars(stmt, payloads) await self.db.flush() - if refresh: - for record in updated_records: - await self.db.refresh(record) - return updated_records + return list(result.all()) async def delete(self, id: Any) -> ModelType | None: """Delete a single record by its primary key identifier. From 9c07432f61983c73162877e679ccb67e109730cc Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sun, 23 Aug 2026 17:00:10 +0330 Subject: [PATCH 03/19] perf(db): optimize delete_multi using DELETE RETURNING clause Refactor WriteRepositoryMixin.delete_multi to leverage SQLAlchemy 2.0 bulk deletion with the eturning() clause. - Eliminate initial SELECT query (get_by_ids) and in-memory record loading. - Replace sequential per-record deletion with a single atomic DELETE statement. - Retrieve and return deleted model instances directly from session.scalars(). --- src/zcore/db/repository.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/zcore/db/repository.py b/src/zcore/db/repository.py index 7f5753c..a2d3177 100644 --- a/src/zcore/db/repository.py +++ b/src/zcore/db/repository.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any, Generic, TypeVar from pydantic import BaseModel -from sqlalchemy import Select, func, inspect, select, insert, update +from sqlalchemy import Select, func, inspect, select, insert, update, delete from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import load_only from sqlalchemy.orm.interfaces import ExecutableOption @@ -346,11 +346,10 @@ async def delete_multi(self, ids: list[Any]) -> Sequence[ModelType]: if not ids: return [] - records = await self.get_by_ids(ids=ids) - for record in records: - await self.db.delete(record) + stmt = delete(self.model).where(self.pk.in_(ids)).returning(self.model) + result = await self.db.scalars(stmt) await self.db.flush() - return records + return list(result.all()) class SearchRepositoryMixin(AbstractRepository[ModelType]): From 965a57be9dec90a7e1a1d0e083acc4ae3a6e6cb9 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sun, 23 Aug 2026 19:19:47 +0330 Subject: [PATCH 04/19] perf(db): optimize update_multi with direct DBAPI executemany execution Refactor WriteRepositoryMixin.update_multi to execute bulk updates directly using SQLAlchemy 2.0 DBAPI executemany batching without pre-fetching. - Replace initial SELECT query and in-memory setattr loops with session.execute(update, payloads). - Synchronize and return updated instances using a single batch get_by_ids query. - Update repository unit tests to validate homogeneous bulk update semantics. --- src/zcore/db/repository.py | 38 +++++++++++++++++++++++--------------- tests/test_repository.py | 11 +++++------ 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/zcore/db/repository.py b/src/zcore/db/repository.py index a2d3177..561b7f8 100644 --- a/src/zcore/db/repository.py +++ b/src/zcore/db/repository.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any, Generic, TypeVar from pydantic import BaseModel -from sqlalchemy import Select, func, inspect, select, insert, update, delete +from sqlalchemy import Select, delete, func, insert, inspect, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import load_only from sqlalchemy.orm.interfaces import ExecutableOption @@ -293,30 +293,38 @@ async def update( async def update_multi( self, data: dict[Any, BaseModel], partial: bool = False, refresh: bool = False ) -> Sequence[ModelType]: - """Batch update multiple database records mapped by their primary keys. + """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 dictionary mapping target primary keys to their respective update schemas. - partial: If True, fields omitted in the schema payload are skipped. Defaults to False. - refresh: If True, refreshes the instance attributes from the database after flushing. - Defaults to False. + data: A mapping of primary keys to their update schemas. Returns: A sequence containing the updated database model instances. + + Raises: + sqlalchemy.orm.exc.StaleDataError: If any target primary key does not exist. """ if not data: return [] - payloads: list[dict[str, Any]] = [] - for pk_val, schema in data.items(): - dump = schema.model_dump(exclude_unset=partial) - dump[self.pk_name] = pk_val - payloads.append(dump) - - stmt = update(self.model).returning(self.model) - result = await self.db.scalars(stmt, payloads) + payloads = [ + { + **schema.model_dump(exclude_unset=partial), + self.pk_name: pk_val, + } + for pk_val, schema in data.items() + ] + + await self.db.execute( + update(self.model), + payloads, + ) await self.db.flush() - return list(result.all()) + + return await self.get_by_ids(ids=list(data.keys())) async def delete(self, id: Any) -> ModelType | None: """Delete a single record by its primary key identifier. diff --git a/tests/test_repository.py b/tests/test_repository.py index 13ddc48..0e8a3a5 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -177,16 +177,15 @@ async def test_repo_update_multi(db_session: Any) -> None: repo = RepoTestRepository(db_session) item1 = await repo.create(RepoTestCreateSchema(name="A", description="D1")) item2 = await repo.create(RepoTestCreateSchema(name="B", description="D2")) - + data = { - item1.id: RepoTestUpdateSchema(name="A_New"), + item1.id: RepoTestUpdateSchema(name="A_New", description="D1"), item2.id: RepoTestUpdateSchema(name="B_New", description="D2_New"), - 99999: RepoTestUpdateSchema(name="C_New") } - - updated = await repo.update_multi(data, partial=True, refresh=True) + + 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 == "A_New" From 08baa45a116e88288743d793ba354dd517bec289 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sun, 23 Aug 2026 19:22:49 +0330 Subject: [PATCH 05/19] feat(kernel): add background_scope and background_task decorator Introduce lifecycle isolation and automatic dependency injection for asynchronous background routines and tasks. - Implement ackground_scope async context manager to manage independent IoC scopes, AsyncSession instances, and ZContext stores. - Implement @background_task decorator with signature inspection and automatic container dependency resolution. --- src/zcore/kernel/di.py | 77 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/src/zcore/kernel/di.py b/src/zcore/kernel/di.py index 734ba2e..1ca3353 100644 --- a/src/zcore/kernel/di.py +++ b/src/zcore/kernel/di.py @@ -6,8 +6,11 @@ against cyclic dependencies. """ +import functools import inspect -from collections.abc import Callable +import uuid +from collections.abc import AsyncGenerator, Callable +from contextlib import asynccontextmanager from contextvars import ContextVar from typing import ( Annotated, @@ -19,6 +22,7 @@ ) from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession T = TypeVar("T") @@ -304,3 +308,74 @@ def __class_getitem__(cls, interface: type[T]) -> Any: An Annotated type wrapper containing the resolved target class. """ return Annotated[interface, Depends(Injector(interface))] + + +@asynccontextmanager +async def background_scope( + inherit_context: bool = True, + **custom_context: Any, +) -> AsyncGenerator[None, None]: + """Provide an isolated IoC, Database Session, and ZContext scope for background tasks. + + Ensures that async background routines execute within an independent transaction + boundary without interfering with or relying upon completed HTTP request scopes. + + Args: + inherit_context: If True, propagates user_id and context from the caller request. + **custom_context: Explicit context key-values to set in the background scope. + """ + from zcore.context.context import _request_context_store + from zcore.db.setup import db_manager + + scope_id = str(uuid.uuid4()) + scope_token = _current_scope_id.set(scope_id) + + initial_store = dict(_request_context_store.get()) if inherit_context else {} + initial_store.update(custom_context) + ctx_token = _request_context_store.set(initial_store) + + try: + async with db_manager.session() as session: + container.register_scoped_instance(AsyncSession, session) + yield + finally: + container.clear_scope(scope_id) + _current_scope_id.reset(scope_token) + _request_context_store.reset(ctx_token) + + +def background_task(func: Callable[..., Any]) -> Callable[..., Any]: + """Decorator that wraps a background task with an isolated scope and auto-resolves dependencies. + + Supports both async coroutines and standard synchronous functions. + """ + + is_coroutine = inspect.iscoroutinefunction(func) + + def _resolve_injections(args: tuple, kwargs: dict) -> dict: + sig = inspect.signature(func) + bound_args = sig.bind_partial(*args, **kwargs) + resolved_kwargs = dict(kwargs) + + for param_name, param in sig.parameters.items(): + if param_name not in bound_args.arguments and param.annotation is not inspect.Parameter.empty: + resolved_kwargs[param_name] = container.resolve(param.annotation) + + return resolved_kwargs + + if is_coroutine: + @functools.wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + async with background_scope(): + final_kwargs = _resolve_injections(args, kwargs) + return await func(*args, **final_kwargs) + + return async_wrapper + else: + @functools.wraps(func) + def sync_wrapper(*args: Any, **kwargs: Any) -> Any: + with background_scope(): + final_kwargs = _resolve_injections(args, kwargs) + return func(*args, **final_kwargs) + + return sync_wrapper \ No newline at end of file From 9ac3c4b480a02132a5c6e666c47dad4f4dd2067b Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Sun, 23 Aug 2026 19:35:22 +0330 Subject: [PATCH 06/19] feat(kernel): add background scope and task decorator with auto-wiring Implement lifecycle isolation and automatic dependency injection for asynchronous and synchronous background operations. - Introduce ackground_scope async context manager to manage independent IoC scopes, AsyncSession instances, and ZContext stores. - Implement @background_task decorator supporting both coroutines and sync functions (via anyio.to_thread.run_sync). - Add signature inspection for auto-wiring dependencies while preserving default arguments and explicit parameter overrides. - Add comprehensive test suite covering lifecycle isolation, context inheritance, error cleanup, and concurrency in test_di.py. --- src/zcore/kernel/di.py | 66 +++++++++++----- tests/test_di.py | 169 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 22 deletions(-) diff --git a/src/zcore/kernel/di.py b/src/zcore/kernel/di.py index 1ca3353..39e3fc6 100644 --- a/src/zcore/kernel/di.py +++ b/src/zcore/kernel/di.py @@ -20,7 +20,7 @@ get_origin, get_type_hints, ) - +from anyio import to_thread from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession @@ -317,23 +317,29 @@ async def background_scope( ) -> AsyncGenerator[None, None]: """Provide an isolated IoC, Database Session, and ZContext scope for background tasks. - Ensures that async background routines execute within an independent transaction - boundary without interfering with or relying upon completed HTTP request scopes. + Ensures that asynchronous background routines execute within an independent transaction + boundary and IoC scope without relying on or interfering with completed HTTP request lifecycles. + Automatically manages database session allocation, context variable isolation, and guaranteed + resource disposal upon exit. Args: - inherit_context: If True, propagates user_id and context from the caller request. - **custom_context: Explicit context key-values to set in the background scope. + inherit_context: If True, clones active request context parameters (e.g., user_id, scopes) + into the background scope. Defaults to True. + **custom_context: Explicit key-value pairs to set or override in the background context store. + + Yields: + None within an active, isolated execution scope. """ from zcore.context.context import _request_context_store from zcore.db.setup import db_manager - + scope_id = str(uuid.uuid4()) scope_token = _current_scope_id.set(scope_id) - + initial_store = dict(_request_context_store.get()) if inherit_context else {} initial_store.update(custom_context) ctx_token = _request_context_store.set(initial_store) - + try: async with db_manager.session() as session: container.register_scoped_instance(AsyncSession, session) @@ -346,23 +352,39 @@ async def background_scope( def background_task(func: Callable[..., Any]) -> Callable[..., Any]: """Decorator that wraps a background task with an isolated scope and auto-resolves dependencies. - - Supports both async coroutines and standard synchronous functions. + + 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). + + Args: + func: The target synchronous or asynchronous callable to execute in the background. + + Returns: + An asynchronous wrapped callable suitable for scheduling with FastAPI BackgroundTasks. """ - is_coroutine = inspect.iscoroutinefunction(func) - - def _resolve_injections(args: tuple, kwargs: dict) -> dict: + + def _resolve_injections(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]: sig = inspect.signature(func) bound_args = sig.bind_partial(*args, **kwargs) resolved_kwargs = dict(kwargs) - + for param_name, param in sig.parameters.items(): - if param_name not in bound_args.arguments and param.annotation is not inspect.Parameter.empty: - resolved_kwargs[param_name] = container.resolve(param.annotation) - + if param_name in bound_args.arguments: + continue + if param.default is not inspect.Parameter.empty: + continue + if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + continue + if param.annotation is not inspect.Parameter.empty: + annotation = param.annotation + if get_origin(annotation) is Annotated: + annotation = get_args(annotation)[0] + resolved_kwargs[param_name] = container.resolve(annotation) + return resolved_kwargs - + if is_coroutine: @functools.wraps(func) async def async_wrapper(*args: Any, **kwargs: Any) -> Any: @@ -373,9 +395,11 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: return async_wrapper else: @functools.wraps(func) - def sync_wrapper(*args: Any, **kwargs: Any) -> Any: - with background_scope(): + async def sync_wrapper(*args: Any, **kwargs: Any) -> Any: + async with background_scope(): final_kwargs = _resolve_injections(args, kwargs) - return func(*args, **final_kwargs) + return await to_thread.run_sync( + functools.partial(func, *args, **final_kwargs) + ) return sync_wrapper \ No newline at end of file diff --git a/tests/test_di.py b/tests/test_di.py index 51c0476..2ad21a1 100644 --- a/tests/test_di.py +++ b/tests/test_di.py @@ -6,7 +6,10 @@ from typing import Any, Generator, Type, get_origin, get_args, Annotated from abc import ABC from fastapi.params import Depends as DependsClass +from sqlalchemy.ext.asyncio import AsyncSession +from zcore.context.context import _request_context_store +from zcore.db.setup import db_manager from zcore.kernel.di import ( container, _current_scope_id, @@ -15,6 +18,8 @@ DIException, Injector, Inject, + background_scope, + background_task, ) class IService: @@ -82,6 +87,14 @@ 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) @@ -300,4 +313,158 @@ def test_deep_circular_dependency() -> None: assert "Circular dependency detected" in str(exc_info.value) assert "DeepA" in str(exc_info.value) assert "DeepB" in str(exc_info.value) - assert "DeepC" in str(exc_info.value) \ No newline at end of file + 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: + db_manager.init_app("sqlite+aiosqlite:///:memory:") + + container.register_scoped(BackgroundTaskWorker, BackgroundTaskWorker) + explicit_worker = BackgroundTaskWorker() + + @background_task + async def override_job(worker: BackgroundTaskWorker) -> uuid.UUID: + return worker.id + + 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: + db_manager.init_app("sqlite+aiosqlite:///:memory:") + + container.register_scoped(BackgroundTaskWorker, BackgroundTaskWorker) + container.register_scoped(SecondaryWorker, SecondaryWorker) + + @background_task + async def multi_dep_job( + w1: BackgroundTaskWorker, + w2: SecondaryWorker, + tag: str = "default_tag" + ) -> tuple[uuid.UUID, uuid.UUID, str]: + return w1.id, w2.id, tag + + id1, id2, tag = await multi_dep_job() + assert isinstance(id1, uuid.UUID) + 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: + db_manager.init_app("sqlite+aiosqlite:///:memory:") + + container.register_scoped(BackgroundTaskWorker, BackgroundTaskWorker) + + @background_task + async def concurrent_job(idx: int, worker: BackgroundTaskWorker) -> tuple[int, uuid.UUID, int]: + session = container.resolve(AsyncSession) + await asyncio.sleep(0.005) + return idx, worker.id, id(session) + + tasks = [concurrent_job(i) for i in range(10)] + results = await asyncio.gather(*tasks) + + worker_ids = {r[1] for r in results} + 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 From 783a981fe670fd0cf26db370a3344d3773fd225b Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Mon, 24 Aug 2026 09:07:36 +0330 Subject: [PATCH 07/19] chore(kernel): update di imports Add required import spacing in the dependency injection module. --- src/zcore/kernel/di.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/zcore/kernel/di.py b/src/zcore/kernel/di.py index 39e3fc6..480d3a1 100644 --- a/src/zcore/kernel/di.py +++ b/src/zcore/kernel/di.py @@ -20,6 +20,7 @@ get_origin, get_type_hints, ) + from anyio import to_thread from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession From bcea060180463fb026508247af03f2662316c638 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Mon, 24 Aug 2026 09:44:03 +0330 Subject: [PATCH 08/19] feat(kernel): export background scope and task utilities Expose ackground_scope and ackground_task through the kernel package for convenient public access. --- src/zcore/kernel/__init__.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/zcore/kernel/__init__.py b/src/zcore/kernel/__init__.py index 0c8fbd3..f088c6f 100644 --- a/src/zcore/kernel/__init__.py +++ b/src/zcore/kernel/__init__.py @@ -1,4 +1,11 @@ -from zcore.kernel.di import Inject, Injector, IoCContainer, 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 @@ -10,6 +17,8 @@ "IoCContainer", "Kernel", "Plugin", + "background_scope", + "background_task", "container", - "on_event", + "on_event" ] From 8e2306f1bbfc98659d24dca68c825639e7cacfac Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Mon, 24 Aug 2026 09:09:31 +0330 Subject: [PATCH 09/19] feat(db): add advanced filter operators Extend the search engine with additional filtering capabilities. - Add contains, startwith, and endwith operators for string and collection fields. - Add etween operator with value validation and type coercion. - Escape LIKE wildcards for string-based pattern matching. --- src/zcore/db/search.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/zcore/db/search.py b/src/zcore/db/search.py index fbae574..5dc2a73 100644 --- a/src/zcore/db/search.py +++ b/src/zcore/db/search.py @@ -42,7 +42,8 @@ class FilterItem(BaseModel): field: str | None = None op: Literal[ - "eq", "ne", "gt", "lt", "ge", "le", "ilike", "in", "is_null", "or", "and" + "eq", "ne", "gt", "lt", "ge", "le", "ilike", "in", "is_null", + "contains", "startwith", "endwith", "between", "or", "and" ] value: Any | None = None items: list[FilterItem] | None = None @@ -422,6 +423,27 @@ def _compare_column(self, col: Any, op: str, value: Any) -> Any: escaped_value = self._escape_like_wildcards(value) return col.ilike(f"%{escaped_value}%", escape="\\") + if op == "startwith": + escaped_value = self._escape_like_wildcards(value) + return col.ilike(f"{escaped_value}%", escape="\\") + + if op == "endwith": + escaped_value = self._escape_like_wildcards(value) + return col.ilike(f"%{escaped_value}", escape="\\") + + if op == "contains": + if hasattr(col.type, "python_type") and col.type.python_type is str: + escaped_value = self._escape_like_wildcards(value) + return col.ilike(f"%{escaped_value}%", escape="\\") + return col.contains(value) + + if op == "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}") + lower = self._coerce_value(col, value[0]) + upper = self._coerce_value(col, value[1]) + return col.between(lower, upper) + coerced_value = self._coerce_value(col, value) if op == "eq": From 111f56e5b628fd69e1d1dfbceac00e2f4bff01ae Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Mon, 24 Aug 2026 09:24:24 +0330 Subject: [PATCH 10/19] feat(db): make search relation depth configurable Allow models to define a custom maximum relation inclusion depth. * Add __max_search_depth__ model configuration with a default depth of 3. * Use the configured depth when validating include relation paths. * Update search tests to reflect the configurable depth behavior. --- src/zcore/db/search.py | 12 ++++-------- tests/test_search.py | 4 ++-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/zcore/db/search.py b/src/zcore/db/search.py index 5dc2a73..51376cc 100644 --- a/src/zcore/db/search.py +++ b/src/zcore/db/search.py @@ -105,6 +105,7 @@ def __init__(self, model: type[ModelType]): self.model = model self.mapper = inspect(model) self.custom_handlers: dict[str, Callable[[Any], Any]] = {} + self.max_depth: int = getattr(model, "__max_search_depth__", 3) def register_handler( self, field_name: str, handler: Callable[[Any], Any] @@ -159,8 +160,8 @@ def _validate_request(self, search_in: SearchRequest, max_depth: int = 3) -> Non """ restricted = set(ctx.restricted_fields) valid_columns = {col.key for col in self.mapper.columns} + max_depth = self.max_depth - MAX_INCLUDE_DEPTH = 3 if search_in.include: for path in search_in.include: if self._is_path_restricted(path, restricted): @@ -169,9 +170,9 @@ def _validate_request(self, search_in: SearchRequest, max_depth: int = 3) -> Non ) parts = path.split(".") - if len(parts) > MAX_INCLUDE_DEPTH + 1: + if len(parts) > max_depth: raise ValidationError( - message=f"Relation inclusion depth of '{path}' exceeds the maximum limit of {MAX_INCLUDE_DEPTH}." + message=f"Relation inclusion depth of '{path}' exceeds the maximum limit of {max_depth}." ) accumulated_path: list[str] = [] @@ -192,11 +193,6 @@ def _validate_request(self, search_in: SearchRequest, max_depth: int = 3) -> Non ) current_model = rel.mapper.class_ - if len(parts) > MAX_INCLUDE_DEPTH: - raise ValidationError( - message=f"Relation inclusion depth of '{path}' exceeds the maximum limit of {MAX_INCLUDE_DEPTH}." - ) - if search_in.sort: for s in search_in.sort: if self._is_path_restricted(s.field, restricted): diff --git a/tests/test_search.py b/tests/test_search.py index d17a634..567c44f 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -175,8 +175,8 @@ def test_search_restricted_field_bypass(field_variant: str) -> None: (["post"], None, ""), (["post.profile"], None, ""), (["post.profile.user"], None, ""), - (["post.profile.user.non_existent"], ValidationError, "Invalid include relation path"), - (["post.profile.user.bio.extra"], ValidationError, "exceeds the maximum limit of 3"), + (["post.profile.non_existent"], ValidationError, "Invalid include relation path"), + (["post.profile.user.extra"], ValidationError, "exceeds the maximum limit of 3"), ] ) def test_search_include_depth_and_relation( From 68ab32c356bb2330d98d8b941dad93bdf09e9843 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Mon, 24 Aug 2026 09:40:34 +0330 Subject: [PATCH 11/19] feat(db): refine search operators and add filter depth tests Standardize string matching operators and expand search engine test coverage. * Rename startwith and endwith to startswith and endswith. * Add comprehensive tests for string matching and etween operators. * Add validation tests for invalid etween values. * Add coverage for configurable model filter depth limits. --- src/zcore/db/search.py | 6 +- tests/test_search.py | 129 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 128 insertions(+), 7 deletions(-) diff --git a/src/zcore/db/search.py b/src/zcore/db/search.py index 51376cc..b273edf 100644 --- a/src/zcore/db/search.py +++ b/src/zcore/db/search.py @@ -43,7 +43,7 @@ class FilterItem(BaseModel): field: str | None = None op: Literal[ "eq", "ne", "gt", "lt", "ge", "le", "ilike", "in", "is_null", - "contains", "startwith", "endwith", "between", "or", "and" + "contains", "startswith", "endswith", "between", "or", "and" ] value: Any | None = None items: list[FilterItem] | None = None @@ -419,11 +419,11 @@ def _compare_column(self, col: Any, op: str, value: Any) -> Any: escaped_value = self._escape_like_wildcards(value) return col.ilike(f"%{escaped_value}%", escape="\\") - if op == "startwith": + if op == "startswith": escaped_value = self._escape_like_wildcards(value) return col.ilike(f"{escaped_value}%", escape="\\") - if op == "endwith": + if op == "endswith": escaped_value = self._escape_like_wildcards(value) return col.ilike(f"%{escaped_value}", escape="\\") diff --git a/tests/test_search.py b/tests/test_search.py index 567c44f..008b7ef 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -42,6 +42,12 @@ 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 @@ -126,19 +132,113 @@ async def test_search_all_operators( assert len(items) == len(expected_ids) assert {item.id for item in items} == set(expected_ids) -def make_nested_filter(depth: int) -> FilterItem: +@pytest.mark.anyio +async def test_search_startswith_operator(db_session: Any, seed_data: None) -> None: + engine = SearchEngine(SearchUser) + + req_adm = SearchRequest(filters=[FilterItem(field="username", op="startswith", value="adm")]) + res_adm = await db_session.execute(engine.build_base_query(req_adm)) + assert {u.id for u in res_adm.scalars().all()} == {1} + + req_user = SearchRequest(filters=[FilterItem(field="username", op="startswith", value="user")]) + res_user = await db_session.execute(engine.build_base_query(req_user)) + assert {u.id for u in res_user.scalars().all()} == {2} + + req_none = SearchRequest(filters=[FilterItem(field="username", op="startswith", value="nonexistent")]) + 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) + + req_percent = SearchRequest(filters=[FilterItem(field="username", op="endswith", value="%")]) + res_percent = await db_session.execute(engine.build_base_query(req_percent)) + assert {u.id for u in res_percent.scalars().all()} == {1} + + req_special = SearchRequest(filters=[FilterItem(field="username", op="endswith", value="special")]) + res_special = await db_session.execute(engine.build_base_query(req_special)) + assert {u.id for u in res_special.scalars().all()} == {2} + + req_st = SearchRequest(filters=[FilterItem(field="username", op="endswith", value="st")]) + 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) + + req_min = SearchRequest(filters=[FilterItem(field="username", op="contains", value="min")]) + res_min = await db_session.execute(engine.build_base_query(req_min)) + assert {u.id for u in res_min.scalars().all()} == {1} + + req_spec = SearchRequest(filters=[FilterItem(field="username", op="contains", value="_spec")]) + res_spec = await db_session.execute(engine.build_base_query(req_spec)) + assert {u.id for u in res_spec.scalars().all()} == {2} + + req_ues = SearchRequest(filters=[FilterItem(field="username", op="contains", value="ues")]) + 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) + + req_1_2 = SearchRequest(filters=[FilterItem(field="id", op="between", value=[1, 2])]) + res_1_2 = await db_session.execute(engine.build_base_query(req_1_2)) + assert {u.id for u in res_1_2.scalars().all()} == {1, 2} + + req_2_3 = SearchRequest(filters=[FilterItem(field="id", op="between", value=[2, 3])]) + 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) + + req_dt = SearchRequest( + filters=[ + FilterItem( + field="created_at", + op="between", + value=["2026-01-01T00:00:00", "2026-01-02T23:59:59"] + ) + ] + ) + 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", + [ + "not_a_list", + [1], + [1, 2, 3], + None, + ] +) +def test_search_between_validation_error(invalid_val: Any) -> None: + engine = SearchEngine(SearchUser) + 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) + +def make_nested_filter(depth: int, field_name: str = "username") -> FilterItem: if depth <= 1: - return FilterItem(field="username", op="eq", value="guest") - return FilterItem(op="and", items=[make_nested_filter(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", [ + (1, False), (2, False), + (3, False), (4, True), ] ) -def test_search_max_filter_depth(depth: int, should_raise: bool) -> None: +def test_search_max_filter_depth_default(depth: int, should_raise: bool) -> None: engine = SearchEngine(SearchUser) nested_filter = make_nested_filter(depth) request = SearchRequest(filters=[nested_filter], size=10) @@ -150,6 +250,27 @@ def test_search_max_filter_depth(depth: int, should_raise: bool) -> None: query = engine.build_base_query(request) assert query is not None +@pytest.mark.parametrize( + "depth, should_raise", + [ + (3, False), + (4, False), + (5, False), + (6, True), + ] +) +def test_search_custom_model_max_depth(depth: int, should_raise: bool) -> None: + engine = SearchEngine(CustomDepthEntity) + nested_filter = make_nested_filter(depth, field_name="username") + request = SearchRequest(filters=[nested_filter], size=10) + if should_raise: + with pytest.raises(ValidationError) as exc_info: + engine.build_base_query(request) + assert "Search query filter structure is too complex" in str(exc_info.value) + else: + query = engine.build_base_query(request) + assert query is not None + @pytest.mark.parametrize( "field_variant", [ From ecbdcec886bf71b18908b2a8c10b9acf58148882 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Mon, 24 Aug 2026 09:48:35 +0330 Subject: [PATCH 12/19] feat(db): add soft delete mixin Introduce a reusable soft-delete mixin for SQLAlchemy models. * Add SoftDeleteMixin with deleted_at tracking and deletion state checks. * Provide soft_delete() and estore() lifecycle methods. * Add scope_query() for filtering out soft-deleted records. * Export SoftDeleteMixin through the public zcore and zcore.db APIs. --- src/zcore/__init__.py | 7 +++++++ src/zcore/db/__init__.py | 2 ++ src/zcore/db/soft_delete.py | 27 +++++++++++++++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 src/zcore/db/soft_delete.py diff --git a/src/zcore/__init__.py b/src/zcore/__init__.py index 3e73391..84fb960 100644 --- a/src/zcore/__init__.py +++ b/src/zcore/__init__.py @@ -7,6 +7,7 @@ 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.soft_delete import SoftDeleteMixin from zcore.db.uow import UnitOfWork from zcore.kernel.di import Inject, container from zcore.kernel.engine import Kernel @@ -35,6 +36,7 @@ "SearchRequest", "Security", "SessionDep", + "SoftDeleteMixin", "UnitOfWork", "ZContext", "ZCoreAPIRoute", @@ -141,5 +143,10 @@ 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 raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/zcore/db/__init__.py b/src/zcore/db/__init__.py index ebb0b51..ee96eb6 100644 --- a/src/zcore/db/__init__.py +++ b/src/zcore/db/__init__.py @@ -10,6 +10,7 @@ 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 __all__ = [ @@ -26,6 +27,7 @@ "SearchEngine", "SearchRequest", "SessionDep", + "SoftDeleteMixin", "UnitOfWork", "db_manager", "dispatch_db_event", diff --git a/src/zcore/db/soft_delete.py b/src/zcore/db/soft_delete.py new file mode 100644 index 0000000..8b978d4 --- /dev/null +++ b/src/zcore/db/soft_delete.py @@ -0,0 +1,27 @@ +from datetime import UTC, datetime + +from sqlalchemy import DateTime, Select +from sqlalchemy.orm import Mapped, mapped_column + + +class SoftDeleteMixin: + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + default=None, + nullable=True, + index=True, + ) + + @property + def is_deleted(self) -> bool: + return self.deleted_at is not None + + @classmethod + 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) + + def restore(self) -> None: + self.deleted_at = None \ No newline at end of file From 5ccbb4d31c0bafcd6c19647d87af7d2ff295f996 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Mon, 24 Aug 2026 09:58:54 +0330 Subject: [PATCH 13/19] feat(db): integrate soft delete with repository queries Automatically apply soft-delete scopes to repository existence checks. * Update exist() to exclude soft-deleted records through scope_query(). * Add comprehensive soft-delete repository integration tests. * Cover filtering, counting, existence checks, ID lookups, search, and restore lifecycle. --- src/zcore/db/repository.py | 3 + tests/test_soft_delete.py | 168 +++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 tests/test_soft_delete.py diff --git a/src/zcore/db/repository.py b/src/zcore/db/repository.py index 561b7f8..8e160d5 100644 --- a/src/zcore/db/repository.py +++ b/src/zcore/db/repository.py @@ -95,6 +95,9 @@ async def exist(self, *criterion: Any, **filters: Any) -> bool: True if matching records are found, False otherwise. """ query = select(self.pk) + scoper = getattr(self.model, "scope_query", None) + if scoper: + query = scoper(query) query = self._apply_filters(query, *criterion, **filters).limit(1) result = await self.db.execute(query) return result.first() is not None diff --git a/tests/test_soft_delete.py b/tests/test_soft_delete.py new file mode 100644 index 0000000..e2699cc --- /dev/null +++ b/tests/test_soft_delete.py @@ -0,0 +1,168 @@ +import uuid +from datetime import datetime +from typing import Any, AsyncGenerator +import pytest +import pytest_asyncio +from pydantic import BaseModel +from sqlalchemy import Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from zcore.db.repository import BaseRepository +from zcore.db.search import FilterItem, SearchRequest +from zcore.db.setup import Base +from zcore.db.soft_delete import SoftDeleteMixin + + +class SoftDeleteTestModel(Base, SoftDeleteMixin): + __tablename__ = f"test_soft_delete_{uuid.uuid4().hex[:6]}" + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100)) + + +class SoftDeleteCreateSchema(BaseModel): + name: str + + +class SoftDeleteUpdateSchema(BaseModel): + name: str | None = None + + +class SoftDeleteRepository(BaseRepository[SoftDeleteTestModel]): + def __init__(self, db: Any) -> None: + super().__init__(SoftDeleteTestModel, db) + + +@pytest_asyncio.fixture(autouse=True) +async def setup_test_tables(test_engine: Any) -> AsyncGenerator[None, None]: + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +def test_soft_delete_instance_state_transitions() -> None: + entity = SoftDeleteTestModel(id=1, name="Item Alpha") + + assert entity.deleted_at is None + assert entity.is_deleted is False + + entity.soft_delete() + assert entity.deleted_at is not None + assert isinstance(entity.deleted_at, datetime) + assert entity.deleted_at.tzinfo is not None + assert entity.is_deleted is True + + entity.restore() + assert entity.deleted_at is None + assert entity.is_deleted is False + + +@pytest.mark.anyio +async def test_soft_delete_repository_query_filtering(db_session: Any) -> None: + repo = SoftDeleteRepository(db_session) + + item1 = await repo.create(SoftDeleteCreateSchema(name="Active 1")) + item2 = await repo.create(SoftDeleteCreateSchema(name="Active 2")) + item3 = await repo.create(SoftDeleteCreateSchema(name="To Delete")) + + item3.soft_delete() + await db_session.flush() + + active_items = await repo.get_list() + assert len(active_items) == 2 + active_ids = {i.id for i in active_items} + assert item1.id in active_ids + assert item2.id in active_ids + assert item3.id not in active_ids + + fetched_active = await repo.get(id=item1.id) + assert fetched_active is not None + assert fetched_active.id == item1.id + + fetched_deleted = await repo.get(id=item3.id) + assert fetched_deleted is None + + +@pytest.mark.anyio +async def test_soft_delete_repository_count_and_exist(db_session: Any) -> None: + repo = SoftDeleteRepository(db_session) + + item1 = await repo.create(SoftDeleteCreateSchema(name="Visible")) + item2 = await repo.create(SoftDeleteCreateSchema(name="Hidden")) + + item2.soft_delete() + await db_session.flush() + + assert await repo.count() == 1 + assert await repo.count(SoftDeleteTestModel.name == "Visible") == 1 + assert await repo.count(SoftDeleteTestModel.name == "Hidden") == 0 + + assert await repo.exist(id=item1.id) is True + assert await repo.exist(id=item2.id) is False + assert await repo.exist(name="Hidden") is False + + +@pytest.mark.anyio +async def test_soft_delete_repository_get_by_ids(db_session: Any) -> None: + repo = SoftDeleteRepository(db_session) + + item1 = await repo.create(SoftDeleteCreateSchema(name="Item 1")) + item2 = await repo.create(SoftDeleteCreateSchema(name="Item 2")) + item3 = await repo.create(SoftDeleteCreateSchema(name="Item 3")) + + item2.soft_delete() + await db_session.flush() + + results = await repo.get_by_ids(ids=[item1.id, item2.id, item3.id]) + assert len(results) == 2 + result_ids = {r.id for r in results} + assert item1.id in result_ids + assert item3.id in result_ids + assert item2.id not in result_ids + + +@pytest.mark.anyio +async def test_soft_delete_search_engine_integration(db_session: Any) -> None: + repo = SoftDeleteRepository(db_session) + + item1 = await repo.create(SoftDeleteCreateSchema(name="Product Alpha")) + item2 = await repo.create(SoftDeleteCreateSchema(name="Product Beta")) + item3 = await repo.create(SoftDeleteCreateSchema(name="Product Gamma")) + + item2.soft_delete() + await db_session.flush() + + req = SearchRequest( + filters=[ + FilterItem(field="name", op="startswith", value="Product") + ] + ) + search_results = await repo.search(req) + assert len(search_results) == 2 + result_ids = {r.id for r in search_results} + assert item1.id in result_ids + assert item3.id in result_ids + assert item2.id not in result_ids + + +@pytest.mark.anyio +async def test_soft_delete_restore_lifecycle_in_db(db_session: Any) -> None: + repo = SoftDeleteRepository(db_session) + + item = await repo.create(SoftDeleteCreateSchema(name="Recoverable Item")) + + item.soft_delete() + await db_session.flush() + + assert await repo.get(id=item.id) is None + assert await repo.count() == 0 + + item.restore() + await db_session.flush() + + restored_item = await repo.get(id=item.id) + assert restored_item is not None + assert restored_item.id == item.id + assert restored_item.is_deleted is False + assert await repo.count() == 1 \ No newline at end of file From 5547ee787674d7bdb7883c3fc38d7f540cd11372 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Mon, 24 Aug 2026 10:11:59 +0330 Subject: [PATCH 14/19] chore(tests): modernize test suite typing and formatting Align the test suite with modern Python typing and formatting conventions. * Replace legacy Type and Optional annotations with built-in generics. * Reorganize imports and apply consistent formatting across tests. * Simplify context managers, exception handling, and unused variable patterns. * Improve test annotations with ClassVar and collections.abc types. --- tests/conftest.py | 20 +++++----- tests/test_cache.py | 15 ++++---- tests/test_cli.py | 5 ++- tests/test_config.py | 9 +++-- tests/test_context.py | 13 ++++--- tests/test_db_lifecycle.py | 16 ++++---- tests/test_di.py | 32 ++++++++-------- tests/test_events.py | 2 + tests/test_exceptions.py | 9 +++-- tests/test_kernel.py | 4 +- tests/test_logging.py | 8 ++-- tests/test_pagination.py | 10 +++-- tests/test_repository.py | 16 ++++---- tests/test_search.py | 14 ++++--- tests/test_security.py | 17 +++++---- tests/test_service.py | 20 +++++----- tests/test_soft_delete.py | 4 +- tests/test_storage.py | 10 +++-- tests/test_streams.py | 9 +++-- tests/test_testing_subsystem.py | 65 ++++++++++++++++++--------------- tests/test_utils.py | 4 +- tests/test_web.py | 45 +++++++++++++---------- 22 files changed, 198 insertions(+), 149 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index b5be98d..d30758e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,18 +1,22 @@ import asyncio +import contextlib import os import shutil -from typing import AsyncGenerator, Any, Generator +from collections.abc import AsyncGenerator, Generator +from typing import Any + import pytest import pytest_asyncio -from httpx import AsyncClient, ASGITransport from fastapi import FastAPI -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, AsyncEngine +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine -from zcore.db.setup import Base, db_manager, get_db -from zcore.kernel.di import container, _current_scope_id -from zcore.kernel.engine import Kernel from zcore.cache.base import close_cache from zcore.config import Settings, initialize_settings +from zcore.db.setup import Base, db_manager, get_db +from zcore.kernel.di import _current_scope_id, container +from zcore.kernel.engine import Kernel + class MockSessionContext: def __init__(self, session: AsyncSession) -> None: @@ -148,10 +152,8 @@ async def cleanup_background_tasks() -> AsyncGenerator[None, None]: task_name = str(task) if "start_eviction_loop" in task_name or "listen_to_redis" in task_name: task.cancel() - try: + with contextlib.suppress(asyncio.CancelledError): await task - except asyncio.CancelledError: - pass @pytest_asyncio.fixture async def test_app(db_session: AsyncSession) -> AsyncGenerator[FastAPI, None]: diff --git a/tests/test_cache.py b/tests/test_cache.py index e83c900..9b4b22b 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1,15 +1,16 @@ -import asyncio -import time import gc import threading -from typing import Any, Type +import time +from typing import Any from unittest.mock import AsyncMock + import pytest from pydantic import BaseModel -from zcore.cache.base import BaseCache, init_cache, close_cache +from zcore.cache.base import BaseCache, close_cache, init_cache from zcore.cache.ttllru_cache import TTLLRUCache, _active_caches + class SampleCachedModel(BaseModel): id: int name: str @@ -95,8 +96,8 @@ async def test_base_cache_redis_fallback( ) async def test_cache_deserialization_types( payload: Any, - target_type: Type[BaseModel] | None, - expected_cls: Type[Any] + target_type: type[BaseModel] | None, + expected_cls: type[Any] ) -> None: cache = BaseCache[Any](prefix="typing_test") await cache.set("payload_key", payload, ttl=5) @@ -164,7 +165,7 @@ def test_ttllru_weakref_gc() -> None: initial_count = len(list(_active_caches)) def create_temporary_cache() -> None: - temp_cache = TTLLRUCache(maxsize=5) + _ = TTLLRUCache(maxsize=5) assert len(list(_active_caches)) == initial_count + 1 create_temporary_cache() diff --git a/tests/test_cli.py b/tests/test_cli.py index 96dd6c1..bd03dac 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,12 +1,13 @@ import re import sys -import pytest - from pathlib import Path from unittest.mock import patch +import pytest + from zcore.cli import main + @pytest.fixture def run_in_tmp_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: monkeypatch.chdir(tmp_path) diff --git a/tests/test_config.py b/tests/test_config.py index 26d70ff..04c84d5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,12 +1,13 @@ import os import sys import tempfile -from typing import Any + import pytest from zcore.config import Settings, get_settings, initialize_settings, settings from zcore.kernel.di import container + @pytest.mark.parametrize( "env_key, env_val, expected_val, check_attr", [ @@ -38,11 +39,11 @@ def test_settings_proxy_resolution(secret_a: str, secret_b: str) -> None: settings_a = Settings(SECRET_KEY=secret_a) initialize_settings(settings_a) - assert settings.SECRET_KEY == secret_a + assert secret_a == settings.SECRET_KEY settings_b = Settings(SECRET_KEY=secret_b) initialize_settings(settings_b) - assert settings.SECRET_KEY == secret_b + assert secret_b == settings.SECRET_KEY def test_settings_type_coercion(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("DEBUG", "False") @@ -99,7 +100,7 @@ class CustomProjectSettings(Settings): resolved_child = container.resolve(CustomProjectSettings) assert resolved_parent is custom_inst assert resolved_child is custom_inst - assert getattr(resolved_parent, "CUSTOM_FIELD") == "custom_value" + assert resolved_parent.CUSTOM_FIELD == "custom_value" def test_settings_get_settings_fallback() -> None: container._singletons.clear() diff --git a/tests/test_context.py b/tests/test_context.py index c978569..738646d 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,9 +1,11 @@ -import uuid import asyncio -from typing import Any, Type +import uuid +from typing import Any + import pytest -from zcore.context.context import ctx, _request_context_store +from zcore.context.context import _request_context_store, ctx + @pytest.mark.parametrize( "user_id_input, expected_output, expected_exception", @@ -18,7 +20,7 @@ def test_set_current_user_id_validation( user_id_input: Any, expected_output: uuid.UUID | None, - expected_exception: Type[Exception] | None + expected_exception: type[Exception] | None ) -> None: token = _request_context_store.set({}) try: @@ -67,8 +69,7 @@ def test_context_managers_cleanup() -> None: new_user = uuid.uuid4() new_fields = {"password"} - with pytest.raises(RuntimeError): - with ctx.scope(user_id=new_user, restricted_fields=new_fields): + with pytest.raises(RuntimeError), ctx.scope(user_id=new_user, restricted_fields=new_fields): assert ctx.user_id == new_user assert ctx.restricted_fields == frozenset(new_fields) raise RuntimeError("Error inside scope") diff --git a/tests/test_db_lifecycle.py b/tests/test_db_lifecycle.py index 1ef7d64..f54c481 100644 --- a/tests/test_db_lifecycle.py +++ b/tests/test_db_lifecycle.py @@ -1,13 +1,15 @@ import uuid -import pytest - +from collections.abc import AsyncGenerator from contextlib import asynccontextmanager -from typing import Any, AsyncGenerator, Type +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch +import pytest + from zcore.db.setup import DatabaseManager from zcore.db.uow import UnitOfWork + @pytest.mark.parametrize( "db_url, pool_size, max_overflow", [ @@ -39,7 +41,7 @@ async def test_db_manager_init(db_url: str, pool_size: int, max_overflow: int) - ] ) @pytest.mark.anyio -async def test_db_session_rollback_on_error(exception_class: Type[Exception]) -> None: +async def test_db_session_rollback_on_error(exception_class: type[Exception]) -> None: manager = DatabaseManager() mock_session = AsyncMock() @@ -106,7 +108,7 @@ async def track_dispatch(event: str, payload: Any) -> list[Any]: ) @pytest.mark.anyio async def test_uow_rollback_clears_events( - exception_class: Type[Exception], + exception_class: type[Exception], events_to_register: list[tuple[str, dict[str, Any]]] ) -> None: session = AsyncMock() @@ -136,7 +138,7 @@ async def test_uow_rollback_clears_events( async def test_db_manager_uninitialized_access() -> None: manager = DatabaseManager() with pytest.raises(RuntimeError): - async with manager.session() as session: + async with manager.session(): pass @pytest.mark.anyio @@ -187,7 +189,7 @@ async def mock_session_ctx() -> AsyncGenerator[AsyncMock, None]: manager._session_factory = mock_session_ctx with pytest.raises(ValueError, match="Internal Error"): - async with manager.session() as session: + async with manager.session(): raise ValueError("Internal Error") mock_session.rollback.assert_called_once() diff --git a/tests/test_di.py b/tests/test_di.py index 2ad21a1..e3ddf2c 100644 --- a/tests/test_di.py +++ b/tests/test_di.py @@ -1,27 +1,29 @@ import asyncio import uuid -import pytest - -from contextlib import contextmanager -from typing import Any, Generator, Type, get_origin, get_args, Annotated from abc import ABC +from collections.abc import Generator +from contextlib import contextmanager +from typing import Annotated, Any, get_args, get_origin + +import pytest from fastapi.params import Depends as DependsClass from sqlalchemy.ext.asyncio import AsyncSession from zcore.context.context import _request_context_store from zcore.db.setup import db_manager from zcore.kernel.di import ( - container, - _current_scope_id, - _scoped_instances, CircularDependencyError, DIException, - Injector, Inject, + Injector, + _current_scope_id, + _scoped_instances, background_scope, background_task, + container, ) + class IService: pass @@ -120,7 +122,7 @@ def reset_container() -> None: (ForwardB, ForwardB), ] ) -def test_resolve_singleton(interface: Type[Any], implementation: Type[Any]) -> None: +def test_resolve_singleton(interface: type[Any], implementation: type[Any]) -> None: instance = implementation() container.register_singleton(interface, instance) @@ -139,8 +141,8 @@ def test_resolve_singleton(interface: Type[Any], implementation: Type[Any]) -> N ] ) def test_resolve_scoped( - interface: Type[Any], - implementation: Type[Any], + interface: type[Any], + implementation: type[Any], scope_1: str, scope_2: str ) -> None: @@ -162,7 +164,7 @@ def test_resolve_scoped( (ForwardB, ForwardB), ] ) -def test_resolve_transient(interface: Type[Any], implementation: Type[Any]) -> None: +def test_resolve_transient(interface: type[Any], implementation: type[Any]) -> None: container.register_transient(interface, implementation) res1 = container.resolve(interface) @@ -178,7 +180,7 @@ def test_resolve_transient(interface: Type[Any], implementation: Type[Any]) -> N {"A": CircA, "B": CircB}, ] ) -def test_circular_dependency(registrations: dict[str, Type[Any]]) -> None: +def test_circular_dependency(registrations: dict[str, type[Any]]) -> None: container.register_transient(CircA, registrations["A"]) container.register_transient(CircB, registrations["B"]) @@ -195,7 +197,7 @@ def test_circular_dependency(registrations: dict[str, Type[Any]]) -> None: (ForwardA, ForwardB), ] ) -def test_forward_reference_resolution(target: Type[Any], dep: 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) @@ -220,7 +222,7 @@ async def run_task(task_id: str) -> tuple[str, Any]: results = await asyncio.gather(*tasks) seen_ids = set() - for task_id, instance in results: + for _task_id, instance in results: assert instance.id not in seen_ids seen_ids.add(instance.id) assert len(seen_ids) == num_tasks diff --git a/tests/test_events.py b/tests/test_events.py index d3352e5..6575160 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -2,10 +2,12 @@ import time import uuid from typing import Any + import pytest from zcore.kernel.events import EventDispatcher, on_event + class DummyContainer: def __init__(self) -> None: self.registry = {} diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 794bf2f..b32fdcc 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,10 +1,10 @@ import uuid -import pytest - from datetime import datetime from decimal import Decimal -from typing import Any, Type +from typing import Any from unittest.mock import patch + +import pytest from fastapi import FastAPI from httpx import ASGITransport, AsyncClient @@ -19,6 +19,7 @@ from zcore.exceptions.handlers import app_exception_handler from zcore.web.response import ResponseWrapper + class CustomPaymentFailed(AppException): status_code = 402 @@ -34,7 +35,7 @@ class CustomPaymentFailed(AppException): ] ) def test_exception_status_codes( - exc_class: Type[AppException], + exc_class: type[AppException], status_code: int, message: str, payload: dict[str, Any] | None diff --git a/tests/test_kernel.py b/tests/test_kernel.py index f55c0cf..21490cb 100644 --- a/tests/test_kernel.py +++ b/tests/test_kernel.py @@ -1,10 +1,12 @@ import uuid + import pytest from fastapi import FastAPI +from zcore.kernel.di import container from zcore.kernel.engine import Kernel from zcore.kernel.events import EventDispatcher -from zcore.kernel.di import container + class TrackedPlugin: def __init__( diff --git a/tests/test_logging.py b/tests/test_logging.py index cd3e465..746f058 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -1,13 +1,15 @@ import importlib import logging import sys -from typing import Any, Type -from unittest.mock import patch, MagicMock +from typing import Any +from unittest.mock import MagicMock, patch + import pytest import structlog from zcore.config import settings + @pytest.mark.parametrize( "is_atty, expected_renderer_cls", [ @@ -18,7 +20,7 @@ def test_logging_format_by_environment( monkeypatch: pytest.MonkeyPatch, is_atty: bool, - expected_renderer_cls: Type[Any] + expected_renderer_cls: type[Any] ) -> None: monkeypatch.setattr(sys.stderr, "isatty", lambda: is_atty) monkeypatch.setattr(settings, "DEBUG", is_atty) diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 42943b2..9f29af6 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -1,10 +1,11 @@ +import datetime import uuid +from collections.abc import AsyncGenerator +from typing import Any + import pytest -import datetime import pytest_asyncio - -from typing import Any, AsyncGenerator -from sqlalchemy import Column, DateTime, Integer, String, select, UUID +from sqlalchemy import UUID, Column, DateTime, Integer, String, select from zcore.db.pagination import ( CursorPagination, @@ -15,6 +16,7 @@ from zcore.db.setup import Base from zcore.exceptions.base import ValidationError + class PaginationTestModel(Base): __tablename__ = f"pagination_test_{uuid.uuid4().hex[:6]}" id = Column(Integer, primary_key=True) diff --git a/tests/test_repository.py b/tests/test_repository.py index 0e8a3a5..2473443 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -1,18 +1,20 @@ import uuid +from collections.abc import AsyncGenerator +from typing import Any +from unittest.mock import AsyncMock + import pytest import pytest_asyncio - from pydantic import BaseModel -from unittest.mock import AsyncMock -from typing import Any, AsyncGenerator from sqlalchemy import Column, Integer, String +from zcore.context.context import ctx +from zcore.db.pagination import CursorParams, PageNumberParams from zcore.db.repository import BaseRepository +from zcore.db.search import FilterItem, SearchRequest from zcore.db.setup import Base -from zcore.db.pagination import PageNumberParams, CursorParams -from zcore.db.search import SearchRequest, FilterItem -from zcore.context.context import ctx -from zcore.exceptions.base import ValidationError, ForbiddenError +from zcore.exceptions.base import ForbiddenError, ValidationError + class RepoTestModel(Base): __tablename__ = f"repo_test_model_{uuid.uuid4().hex[:6]}" diff --git a/tests/test_search.py b/tests/test_search.py index 008b7ef..02b0ee0 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1,16 +1,18 @@ import uuid as uuid_pkg -import pytest -import pytest_asyncio +from collections.abc import AsyncGenerator, Generator from datetime import datetime +from typing import Any +import pytest +import pytest_asyncio +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Uuid from sqlalchemy.orm import relationship -from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Boolean, Uuid -from typing import Any, AsyncGenerator, Generator, Type -from zcore.db.setup import Base from zcore.db.search import FilterItem, SearchEngine, SearchRequest, SortItem +from zcore.db.setup import Base from zcore.exceptions.base import ForbiddenError, ValidationError + class SearchUser(Base): __tablename__ = f"search_users_{uuid_pkg.uuid4().hex[:6]}" id = Column(Integer, primary_key=True) @@ -302,7 +304,7 @@ def test_search_restricted_field_bypass(field_variant: str) -> None: ) def test_search_include_depth_and_relation( paths: list[str], - expected_error: Type[Exception] | None, + expected_error: type[Exception] | None, error_message: str ) -> None: engine = SearchEngine(SearchComment) diff --git a/tests/test_security.py b/tests/test_security.py index 3ca1b87..e52e7b3 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -1,19 +1,20 @@ -import uuid import time -import pytest - -from typing import Any, Type +import uuid from datetime import timedelta +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest from pydantic import BaseModel -from unittest.mock import MagicMock, AsyncMock, patch from zcore.config import settings from zcore.context import ctx from zcore.exceptions import AuthError, ForbiddenError from zcore.security import BaseAuth, HasScopes, Security + class MockUser: - def __init__(self, user_id: uuid.UUID, is_active: bool, is_superuser: bool, scopes: Any, all_restricted_fields: list[str] = None) -> None: + def __init__(self, user_id: uuid.UUID, is_active: bool, is_superuser: bool, scopes: Any, all_restricted_fields: list[str] | None = None) -> None: self.id = user_id self.is_active = is_active self.is_superuser = is_superuser @@ -86,7 +87,7 @@ async def test_scope_permissions( user_superuser: bool, user_scopes: set[str], required_scopes: list[str], - expected_error: Type[Exception] | None + expected_error: type[Exception] | None ) -> None: permission = HasScopes(*required_scopes) mock_request = MagicMock() @@ -234,6 +235,7 @@ class CustomSettings(Settings): try: from importlib import reload + import zcore.security.security reload(zcore.security.security) @@ -242,6 +244,7 @@ class CustomSettings(Settings): finally: container._singletons = original_singletons from importlib import reload + import zcore.security.security reload(zcore.security.security) diff --git a/tests/test_service.py b/tests/test_service.py index e573fda..a91f5a8 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1,17 +1,19 @@ import uuid +from collections.abc import Sequence +from typing import Any +from unittest.mock import AsyncMock, MagicMock + import pytest from pydantic import BaseModel - -from typing import Any, Type, Sequence, Optional -from unittest.mock import AsyncMock, MagicMock from sqlalchemy import Column, Integer, String -from zcore.db.setup import Base -from zcore.db.search import SearchRequest from zcore.db.pagination import PaginatedResult +from zcore.db.search import SearchRequest +from zcore.db.setup import Base from zcore.exceptions.base import EntityNotFound from zcore.service.base import BaseService + class ServiceTestModel(Base): __tablename__ = f"service_test_{uuid.uuid4().hex[:6]}" id = Column(Integer, primary_key=True) @@ -24,7 +26,7 @@ class ServiceTestUpdateSchema(BaseModel): name: str class HookTrackingService(BaseService[ServiceTestModel]): - def __init__(self, model: Type[ServiceTestModel], repository: Any) -> None: + def __init__(self, model: type[ServiceTestModel], repository: Any) -> None: super().__init__(model, repository) self.hooks_called: list[str] = [] @@ -39,7 +41,7 @@ async def post_get_multi(self, models: Sequence[ServiceTestModel]) -> Sequence[S m.name = m.name + "_GETMULTIHOCKED" return models - async def pre_create(self, schema: ServiceTestCreateSchema) -> Optional[dict[str, Any]]: + async def pre_create(self, schema: ServiceTestCreateSchema) -> dict[str, Any] | None: self.hooks_called.append("pre_create") schema.name = "HOOKED_" + schema.name if schema.name == "HOOKED_Base": @@ -60,7 +62,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) -> Optional[dict[str, Any]]: + async def pre_update(self, id: 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 +73,7 @@ async def post_update(self, model: ServiceTestModel) -> None: async def pre_update_multi(self, data: dict[Any, BaseModel], partial: bool) -> None: self.hooks_called.append("pre_update_multi") - for k, v in data.items(): + for _k, v in data.items(): v.name = "BULK_UPD_" + v.name async def post_update_multi(self, models: Sequence[ServiceTestModel]) -> None: diff --git a/tests/test_soft_delete.py b/tests/test_soft_delete.py index e2699cc..a3156fa 100644 --- a/tests/test_soft_delete.py +++ b/tests/test_soft_delete.py @@ -1,6 +1,8 @@ import uuid +from collections.abc import AsyncGenerator from datetime import datetime -from typing import Any, AsyncGenerator +from typing import Any + import pytest import pytest_asyncio from pydantic import BaseModel diff --git a/tests/test_storage.py b/tests/test_storage.py index 3380d83..1ac662e 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -1,19 +1,21 @@ import os +from collections.abc import AsyncGenerator from io import BytesIO -from typing import AsyncGenerator -import pytest + import aiofiles +import pytest from fastapi import UploadFile from zcore.exceptions.base import AppException, ValidationError +from zcore.kernel.di import container +from zcore.storage.base import StorageProvider, get_storage_provider from zcore.storage.local import LocalStorageProvider from zcore.storage.validators import ( FileExtensionValidator, MaxFileSizeValidator, SafeMimeTypeValidator, ) -from zcore.storage.base import get_storage_provider, StorageProvider -from zcore.kernel.di import container + def create_mock_upload_file(content: bytes, filename: str, size: int | None = None) -> UploadFile: file_obj = BytesIO(content) diff --git a/tests/test_streams.py b/tests/test_streams.py index cd814eb..a32c773 100644 --- a/tests/test_streams.py +++ b/tests/test_streams.py @@ -1,12 +1,15 @@ import asyncio -import uuid import json -from typing import Any, AsyncGenerator +import uuid +from collections.abc import AsyncGenerator +from typing import Any from unittest.mock import AsyncMock, MagicMock + import pytest -from zcore.web.streams import StreamManager, init_stream_redis import zcore.web.streams as streams_module +from zcore.web.streams import StreamManager, init_stream_redis + @pytest.fixture(autouse=True) def bypass_redis_pubsub(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/test_testing_subsystem.py b/tests/test_testing_subsystem.py index d8cfa24..b2fd3af 100644 --- a/tests/test_testing_subsystem.py +++ b/tests/test_testing_subsystem.py @@ -1,17 +1,26 @@ -import pytest import asyncio import uuid - from contextlib import asynccontextmanager -from fastapi import FastAPI, Depends -from sqlalchemy import String, select, event -from sqlalchemy.orm import Mapped, mapped_column +from typing import Any, ClassVar + +import pytest +from fastapi import Depends, FastAPI +from sqlalchemy import String, event, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Mapped, mapped_column -from zcore import Base, container, settings, get_db, ctx, db_manager -from zcore.testing import ZTestClient, BaseZTest, ZTestFixture, ZTest, ContainerSandbox, DatabaseRollback -from zcore.security import get_current_user_stub, UserProtocol +from zcore import Base, container, ctx, db_manager, get_db, settings from zcore.context.context import _request_context_store +from zcore.security import UserProtocol, get_current_user_stub +from zcore.testing import ( + BaseZTest, + ContainerSandbox, + DatabaseRollback, + ZTest, + ZTestClient, + ZTestFixture, +) + class DummyTask(Base): __tablename__ = "dummy_tasks" @@ -92,12 +101,11 @@ async def test_database_rollback_isolation(): assert res.status_code == 200 task_id = res.json()["id"] - async with ZTestClient(dummy_app, use_db=True) as client: - async with db_manager.session() as session: - res = await session.execute( - select(DummyTask).where(DummyTask.id == uuid.UUID(task_id)) - ) - assert res.scalars().first() is None + async with ZTestClient(dummy_app, use_db=True) as client, db_manager.session() as session: + res = await session.execute( + select(DummyTask).where(DummyTask.id == uuid.UUID(task_id)) + ) + assert res.scalars().first() is None @pytest.mark.asyncio async def test_container_sandbox(): @@ -107,7 +115,7 @@ class MockService: interface = MockService implementation = MockService() - async with ZTestClient(dummy_app, use_db=False) as client: + async with ZTestClient(dummy_app, use_db=False): container.register_singleton(interface, implementation) assert container.resolve(interface) is implementation @@ -158,7 +166,7 @@ async def test_extra_context_variables(): user_id=uid, use_db=False, extra_context={"restricted_fields": frozenset(restricted)} - ) as client: + ): assert ctx.restricted_fields == frozenset(restricted) @pytest.mark.asyncio @@ -166,8 +174,8 @@ class TestClassBasedExecution(BaseZTest): app = dummy_app user_id = uuid.uuid4() is_superuser = True - scopes = ["admin:delete"] - extra_user_attrs = {"phone_number": "09121111111"} + scopes: ClassVar[list[str]] = ["admin:delete"] + extra_user_attrs: ClassVar[dict[str, Any]] = {"phone_number": "09121111111"} async def test_class_based_test_run(self): async with self.run() as client: @@ -187,12 +195,11 @@ async def test_database_rollback_nested_commit(): assert res.status_code == 200 task_id = res.json()["id"] - async with ZTestClient(dummy_app, use_db=True) as client: - async with db_manager.session() as session: - res = await session.execute( - select(DummyTask).where(DummyTask.id == uuid.UUID(task_id)) - ) - assert res.scalars().first() is None + async with ZTestClient(dummy_app, use_db=True) as client, db_manager.session() as session: + res = await session.execute( + select(DummyTask).where(DummyTask.id == uuid.UUID(task_id)) + ) + assert res.scalars().first() is None @pytest.mark.asyncio async def test_database_rollback_uninitialized_engine(monkeypatch: pytest.MonkeyPatch): @@ -231,7 +238,7 @@ async def test_app_lifespan_execution(): global lifespan_events lifespan_events.clear() - async with ZTestClient(lifespan_app, use_db=False) as client: + async with ZTestClient(lifespan_app, use_db=False): assert "started" in lifespan_events assert "shutdown" not in lifespan_events @@ -266,7 +273,7 @@ async def test_user_context_exception_safety(): original_store = dict(_request_context_store.get()) try: - async with ZTestClient(dummy_app, user_id=uuid.uuid4(), use_db=False) as client: + async with ZTestClient(dummy_app, user_id=uuid.uuid4(), use_db=False): raise ValueError("Forced error within execution block") except ValueError: pass @@ -278,10 +285,10 @@ async def test_nested_ztest_clients(): user_id_outer = uuid.uuid4() user_id_inner = uuid.uuid4() - async with ZTestClient(dummy_app, user_id=user_id_outer, use_db=False) as client_outer: + async with ZTestClient(dummy_app, user_id=user_id_outer, use_db=False): assert ctx.user_id == user_id_outer - async with ZTestClient(dummy_app, user_id=user_id_inner, use_db=False) as client_inner: + async with ZTestClient(dummy_app, user_id=user_id_inner, use_db=False): assert ctx.user_id == user_id_inner assert ctx.user_id == user_id_outer @@ -293,7 +300,7 @@ class DevelopmentCustomDep: dummy_app.dependency_overrides[DevelopmentCustomDep] = lambda: "developer_preset" - async with ZTestClient(dummy_app, user_id=uuid.uuid4(), use_db=False) as client: + async with ZTestClient(dummy_app, user_id=uuid.uuid4(), use_db=False): assert dummy_app.dependency_overrides.get(DevelopmentCustomDep)() == "developer_preset" assert dummy_app.dependency_overrides.get(DevelopmentCustomDep)() == "developer_preset" diff --git a/tests/test_utils.py b/tests/test_utils.py index 31f4b72..ee9a401 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,13 +1,15 @@ import datetime -from decimal import Decimal import uuid +from decimal import Decimal from typing import Any + import pytest from zcore.exceptions.base import ValidationError from zcore.utils.helpers import json_dumps, json_loads, slugify from zcore.utils.validators import validate_json_schema + class DummyUser: def __init__(self, name: str) -> None: self.name = name diff --git a/tests/test_web.py b/tests/test_web.py index d18016d..e7b3f11 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -1,27 +1,28 @@ -import uuid -import inspect import asyncio -import pytest +import inspect +import uuid +from contextlib import asynccontextmanager, suppress +from typing import Any, ClassVar +from unittest.mock import AsyncMock, MagicMock -from typing import Any -from pydantic import BaseModel -from contextlib import asynccontextmanager -from fastapi import FastAPI, APIRouter +import pytest +from fastapi import APIRouter, FastAPI from httpx import ASGITransport, AsyncClient -from unittest.mock import MagicMock, AsyncMock +from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession -from zcore.kernel.di import container, _current_scope_id +from zcore.context.context import ZContext +from zcore.db.pagination import PageNumberPagination, PageNumberParams, PaginatedResult +from zcore.db.setup import 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.web.api_router import ZCoreRequest from zcore.web.base_router import BaseRouter, RouteKey from zcore.web.middleware import RequestLogMiddleware, ScopedDependencyMiddleware from zcore.web.projection import Zchema -from zcore.web.api_router import ZCoreRequest from zcore.web.streams import StreamManager, init_stream_redis -from zcore.context.context import ZContext -from zcore.exceptions.base import EntityNotFound, AppException -from zcore.exceptions.handlers import app_exception_handler -from zcore.db.setup import db_manager -from zcore.db.pagination import PageNumberPagination, PaginatedResult, PageNumberParams + class DummyModel: __tablename__ = "dummy" @@ -393,7 +394,7 @@ async def check_scope() -> dict[str, Any]: assert data["scope_id"] is not None assert data["session_is_mock"] is True assert _current_scope_id.get() is None - with pytest.raises(Exception): + with pytest.raises((TypeError, Exception)): container.resolve(AsyncSession) @pytest.mark.anyio @@ -417,7 +418,13 @@ class SortRouter(BaseRouter[DummyCreate, DummyUpdate]): schema_out = DummyOut service = DummyService prefix = "/test" - exclude = {RouteKey.POST, RouteKey.GET_ALL, RouteKey.UPDATE, RouteKey.PATCH, RouteKey.DELETE} + exclude: ClassVar[set[RouteKey]] = { + RouteKey.POST, + RouteKey.GET_ALL, + RouteKey.UPDATE, + RouteKey.PATCH, + RouteKey.DELETE, + } router_inst = SortRouter() paths = [r.path for r in router_inst.router.routes] assert "/test/search" in paths @@ -448,10 +455,8 @@ async def mock_listen(): msg = queue.get_nowait() assert msg == {"msg": "local"} for i in range(105): - try: + with suppress(asyncio.QueueFull): queue.put_nowait({"msg": f"flood_{i}"}) - except asyncio.QueueFull: - pass await sm._local_publish(u_id, {"msg": "overflow"}) assert u_id not in sm.users_queues await sm.unsubscribe(u_id, queue) From 556bf85bf81e47c489f5e768cab2648fd3acdffe Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Mon, 24 Aug 2026 12:24:21 +0330 Subject: [PATCH 15/19] feat(cli): introduce interactive project scaffolding - Add interactive CLI dashboard with Questionary and Rich UI. - Support SQLite, PostgreSQL, and MySQL project initialization. - Add optional virtualenv setup with pip or uv. - Add configurable domain layer scaffolding with template/blank modes. - Add project discovery and project-aware command execution. - Improve env generation, secret generation, and server runner UX. - Add CLI tests for database drivers, project detection, scaffolding, and commands. --- pyproject.toml | 2 + src/zcore/cli/__init__.py | 361 ++++++++++++++++++++++++++----------- src/zcore/cli/commands.py | 340 +++++++++++++++++++++++----------- src/zcore/cli/templates.py | 20 +- src/zcore/cli/ui.py | 44 +++++ tests/test_cli.py | 220 +++++++++++----------- 6 files changed, 665 insertions(+), 322 deletions(-) create mode 100644 src/zcore/cli/ui.py diff --git a/pyproject.toml b/pyproject.toml index 282974f..46d8514 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,8 @@ dependencies = [ "jsonschema>=4.21.0", "python-multipart>=0.0.9", "uvicorn>=0.22.0", + "questionary>=2.0.1", + "rich>=13.7.0", ] [project.optional-dependencies] diff --git a/src/zcore/cli/__init__.py b/src/zcore/cli/__init__.py index 66e8422..504c290 100644 --- a/src/zcore/cli/__init__.py +++ b/src/zcore/cli/__init__.py @@ -1,113 +1,272 @@ import argparse -import secrets +import shutil import sys +from pathlib import Path -from zcore.cli.commands import gen_env, init_project, run_server, start_app +import questionary +from questionary import Choice +from zcore.cli.commands import ( + DB_DRIVERS, + find_zcore_projects, + gen_env, + gen_secret, + init_project, + is_zcore_project, + run_server, + start_app, +) +from zcore.cli.ui import ( + ZCORE_ACCENT, + ZCORE_PRIMARY, + console, + custom_style, + print_banner, + print_cancelled, + print_step_header, +) -def main() -> None: - """ - The main entry point for the ZCore CLI. - This function is bound to the 'zc' shell command. - """ - parser = argparse.ArgumentParser(description="ZCore CLI Tool", prog="zc") - - parser.add_argument( - "--version", action="store_true", help="Show the active ZCore Framework version" - ) - - subparsers = parser.add_subparsers( - dest="command", help="Available Framework Orchestration Commands" - ) - - init_parser = subparsers.add_parser( - "init", help="Initializes a new ZCore project structure" - ) - init_parser.add_argument( - "name", type=str, help="Name of your project directory (e.g., core_api)" - ) - - startapp_parser = subparsers.add_parser( - "startapp", - help="Creates a modular ZCore app (domain) inside the current project", - ) - startapp_parser.add_argument( - "name", type=str, help="Name of the app/module (e.g., payment_gateway)" - ) - - startapp_parser.add_argument( - "-t", - "--template", - action="store_true", - help="Generate files populated with ZCore boilerplate templates", - ) - - startapp_parser.add_argument( - "--test", action="store_true", help="Generate test files for the app" - ) - - subparsers.add_parser("run", help="Launches the local Uvicorn development server") - - subparsers.add_parser( - "gensecret", - help="Generates a cryptographically secure 64-character hex string for production SECRET_KEY", - ) - - genenv_parser = subparsers.add_parser( - "genenv", - help="Generates a template .env file based on the active Settings class fields", - ) - genenv_parser.add_argument( - "-o", - "--output", - type=str, - default=".env.example", - help="Target output filepath (default: .env.example)", - ) - genenv_parser.add_argument( - "-f", - "--force", - action="store_true", - help="Overwrite the file if it already exists", - ) - - args = parser.parse_args() - - if args.version: - print("ZCore Framework - Version 0.1.0-Beta") - sys.exit(0) +LAYER_CHOICES = [ + Choice("SQLAlchemy 2.0 Model (models.py)", value="models"), + Choice("Zchema Pydantic V2 Schemas (schemas.py)", value="schemas"), + Choice("Base Repository Layer (repositories.py)", value="repositories"), + Choice("Base Service Layer (services.py)", value="services"), + Choice("FastAPI Base Router (routers.py)", value="routers"), + Choice("Modular Lifecycle Plugin (plugin.py)", value="plugin"), + Choice("Pytest Async Test Suite (tests.py)", value="tests"), +] + + +def resolve_target_project(subprojects: list[str], prompt_msg: str) -> Path | None: + print_step_header(prompt_msg) + chosen = questionary.select( + "Select target project:", + choices=[Choice(p, value=p) for p in subprojects], + style=custom_style, + ).ask() + return Path(chosen) if chosen else None + + +def prompt_init_interactive(project_name: str | None = None, db_driver: str | None = None) -> None: + print_step_header("Initialize New ZCore Project") + + if not project_name: + project_name = questionary.text("Project directory name:", default="core_api", style=custom_style).ask() + if not project_name: + print_cancelled() + return + console.print("[dim]│[/dim]") + + if not db_driver: + db_driver = questionary.select( + "Select primary database driver:", + choices=[ + Choice("SQLite (aiosqlite) — Lightweight Dev", value="sqlite"), + Choice("PostgreSQL (asyncpg) — Production Ready", value="postgres"), + Choice("MySQL (aiomysql) — Async Driver", value="mysql"), + ], + style=custom_style, + ).ask() + if not db_driver: + print_cancelled() + return + console.print("[dim]│[/dim]") + + install_deps = questionary.select( + "Initialize virtual environment (.venv) and install dependencies?", + choices=[ + Choice("Yes — Create .venv and install packages now", value=True), + Choice("No — Skip, I will configure it manually later", value=False), + ], + style=custom_style, + ).ask() + + pkg_manager = "pip" + if install_deps: + console.print("[dim]│[/dim]") + choices = [] + if shutil.which("uv") is not None: + choices.append(Choice("uv (Ultra-fast package manager) [Recommended]", value="uv")) + choices.append(Choice("pip (Standard Python package installer)", value="pip")) + + pkg_manager = questionary.select( + "Select package installer:", + choices=choices, + style=custom_style, + ).ask() + + init_project(project_name, db_driver=db_driver, install_deps=bool(install_deps), pkg_manager=pkg_manager or "pip") + + +def prompt_startapp_interactive(app_name: str | None = None, target_dir: Path | None = None) -> None: + print_step_header("Scaffold ZCore Domain App") + + if not app_name: + app_name = questionary.text("Domain App / Module name (snake_case):", default="order_management", style=custom_style).ask() + if not app_name: + print_cancelled() + return + console.print("[dim]│[/dim]") + + scaffold_mode = questionary.select( + "How would you like to scaffold the domain app?", + choices=[ + Choice("1. Full Boilerplate — Populate all layers with ZCore templates", value="template"), + Choice("2. Clean / Blank — Create clean, empty files for all layers", value="blank"), + Choice("3. Custom / Mixed — Select which layers have templates (others as blank files)", value="custom"), + ], + style=custom_style, + ).ask() + + if not scaffold_mode: + print_cancelled() + return + + console.print("[dim]│[/dim]") + + if scaffold_mode in ("template", "blank"): + selected = questionary.checkbox( + "Select architectural layers to generate:", + choices=[Choice(c.title, value=c.value, checked=True) for c in LAYER_CHOICES], + style=custom_style, + ).ask() + if not selected: + console.print("[dim]ā””[/dim] [red]Cancelled (no layers selected).[/red]\n") + return + start_app(app_name, target_dir=target_dir, with_template=(scaffold_mode == "template"), selected_components=selected) + + elif scaffold_mode == "custom": + templated = questionary.checkbox( + "Select layers to populate with boilerplate templates (unchecked will be blank files):", + choices=[Choice(c.title, value=c.value, checked=False) for c in LAYER_CHOICES], + style=custom_style, + ).ask() + if templated is None: + print_cancelled() + return + start_app(app_name, target_dir=target_dir, templated_components=templated) - if args.command == "init": - project_name = args.name - if not project_name.isidentifier(): - print( - f"āŒ Error: '{project_name}' is not a valid directory name in Python." - ) - sys.exit(1) - init_project(project_name) - - elif args.command == "startapp": - app_name = args.name - if not app_name.isidentifier(): - print(f"āŒ Error: '{app_name}' is not a valid Python module name.") - print("šŸ‘‰ Recommendation: Use snake_case names (e.g. order_management)") - sys.exit(1) - start_app(app_name, with_template=args.template, with_test=args.test) - - elif args.command == "run": - run_server() - - elif args.command == "gensecret": - print("šŸ”‘ Generated Cryptographic Secret Key:") - print(secrets.token_hex(32)) - - elif args.command == "genenv": - gen_env(args.output, args.force) +def interactive_dashboard() -> None: + print_banner() + + is_inside = is_zcore_project() + subprojects = find_zcore_projects() + + if is_inside: + current_name = Path.cwd().name + console.print(f" [dim]Context:[/dim] [bold {ZCORE_PRIMARY}]Active Project ({current_name})[/bold {ZCORE_PRIMARY}]\n") + choices = [ + Choice("🧩 startapp — Scaffold a modular domain app / plugin", value="startapp"), + Choice("⚔ run — Launch Uvicorn development server", value="run"), + Choice("šŸ“‹ genenv — Generate template .env from Settings class", value="genenv"), + Choice("šŸ”‘ gensecret — Generate cryptographically secure SECRET_KEY", value="gensecret"), + Choice("šŸ“¦ init — Scaffold another ZCore project", value="init"), + Choice("🚪 exit — Exit CLI", value="exit"), + ] + elif subprojects: + console.print(f" [dim]Context:[/dim] [bold {ZCORE_ACCENT}]Workspace with {len(subprojects)} detected project(s): {', '.join(subprojects)}[/bold {ZCORE_ACCENT}]\n") + choices = [ + Choice("⚔ run — Launch a project dev server", value="run"), + Choice("🧩 startapp — Scaffold a domain app inside a project", value="startapp"), + Choice("šŸ“‹ genenv — Generate .env for a project", value="genenv"), + Choice("šŸ“¦ init — Scaffold a new ZCore project", value="init"), + Choice("šŸ”‘ gensecret — Generate cryptographically secure SECRET_KEY", value="gensecret"), + Choice("🚪 exit — Exit CLI", value="exit"), + ] else: - parser.print_help() + choices = [ + Choice("šŸ“¦ init — Scaffold a new full ZCore project", value="init"), + Choice("šŸ”‘ gensecret — Generate cryptographically secure SECRET_KEY", value="gensecret"), + Choice("🚪 exit — Exit CLI", value="exit"), + ] + + action = questionary.select( + "What framework task would you like to perform?", + choices=choices, + style=custom_style, + ).ask() + + if action == "init": + prompt_init_interactive() + elif action == "startapp": + target_dir = resolve_target_project(subprojects, "Select project to add domain module") if not is_inside and subprojects else None + if is_inside or target_dir or not subprojects: + prompt_startapp_interactive(target_dir=target_dir) + elif action == "run": + target_dir = resolve_target_project(subprojects, "Select project to launch server") if not is_inside and subprojects else None + if is_inside or target_dir or not subprojects: + run_server(project_dir=target_dir) + elif action == "gensecret": + gen_secret() + elif action == "genenv": + target_dir = resolve_target_project(subprojects, "Select project to generate .env") if not is_inside and subprojects else None + if is_inside or target_dir or not subprojects: + print_step_header("Generate Configuration Schema (.env.example)") + filename = questionary.text("Target output filename:", default=".env.example", style=custom_style).ask() + if filename: + gen_env(output_file=filename, force=True, project_dir=target_dir) + elif action == "exit" or action is None: + console.print("\n[dim]šŸ‘‹ Cancelled.[/dim]\n") + sys.exit(0) + + +def main() -> None: + try: + parser = argparse.ArgumentParser(description="ZCore CLI Tool", prog="zc") + parser.add_argument("--version", action="store_true", help="Show the active ZCore Framework version") + + subparsers = parser.add_subparsers(dest="command", help="Available Framework Orchestration Commands") + + init_parser = subparsers.add_parser("init", help="Initializes a new ZCore project structure") + init_parser.add_argument("name", nargs="?", type=str, default=None, help="Name of your project directory") + init_parser.add_argument("--db", type=str, default=None, choices=list(DB_DRIVERS.keys()), help="Target database driver") + init_parser.add_argument("-y", "--yes", action="store_true", help="Skip interactive prompts and use defaults") + + startapp_parser = subparsers.add_parser("startapp", help="Creates a modular ZCore app inside the project") + startapp_parser.add_argument("name", nargs="?", type=str, default=None, help="Name of the app/module") + startapp_parser.add_argument("--template", action=argparse.BooleanOptionalAction, default=True, help="Populate with boilerplate templates") + startapp_parser.add_argument("--test", action=argparse.BooleanOptionalAction, default=True, help="Generate test suite") + startapp_parser.add_argument("-y", "--yes", action="store_true", help="Generate all standard architectural layers automatically") + + subparsers.add_parser("run", help="Launches the local Uvicorn development server") + subparsers.add_parser("gensecret", help="Generates a cryptographically secure 64-character SECRET_KEY") + + genenv_parser = subparsers.add_parser("genenv", help="Generates template .env based on Settings") + genenv_parser.add_argument("-o", "--output", type=str, default=".env.example", help="Target output filepath") + genenv_parser.add_argument("-f", "--force", action="store_true", help="Overwrite existing file") + + 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]") + sys.exit(0) + + if args.command == "init": + if args.yes: + init_project(args.name or "core_api", db_driver=args.db or "sqlite", install_deps=False) + else: + prompt_init_interactive(project_name=args.name, db_driver=args.db) + + elif args.command == "startapp": + if args.yes and args.name: + start_app(args.name, with_template=args.template, with_test=args.test) + else: + prompt_startapp_interactive(app_name=args.name) + + elif args.command == "run": + run_server() + elif args.command == "gensecret": + gen_secret() + elif args.command == "genenv": + gen_env(args.output, args.force) + else: + interactive_dashboard() + + except KeyboardInterrupt: + console.print("\n[dim]šŸ‘‹ Operation cancelled by user.[/dim]\n") sys.exit(0) if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/src/zcore/cli/commands.py b/src/zcore/cli/commands.py index 4a31dcf..b8f0a63 100644 --- a/src/zcore/cli/commands.py +++ b/src/zcore/cli/commands.py @@ -1,10 +1,17 @@ +import contextlib +import importlib import os import secrets import subprocess import sys +import time from pathlib import Path -from .templates import ( +from pydantic_core import PydanticUndefined +from rich.panel import Panel +from rich.tree import Tree + +from zcore.cli.templates import ( ENV_TEMPLATE, GITIGNORE_TEMPLATE, MAIN_PY_TEMPLATE, @@ -17,56 +24,174 @@ SERVICE_TEMPLATE, TEST_TEMPLATE, ) +from zcore.cli.ui import ( + ZCORE_ACCENT, + ZCORE_MUTED, + ZCORE_PRIMARY, + ZCORE_TEXT, + console, + print_step_footer, + print_step_header, +) +from zcore.config import Settings, get_settings + +DB_DRIVERS = { + "sqlite": {"url": "sqlite+aiosqlite:///zcore_dev.db", "pkg": ""}, + "postgres": {"url": "postgresql+asyncpg://postgres:postgres@127.0.0.1:5432/zcore_dev", "pkg": "\nasyncpg>=0.29.0"}, + "mysql": {"url": "mysql+aiomysql://root:root@127.0.0.1:3306/zcore_dev", "pkg": "\naiomysql>=0.2.0"}, +} + +COMPONENT_TEMPLATES = { + "models": ("models.py", MODEL_TEMPLATE), + "schemas": ("schemas.py", SCHEMA_TEMPLATE), + "repositories": ("repositories.py", REPOSITORY_TEMPLATE), + "services": ("services.py", SERVICE_TEMPLATE), + "routers": ("routers.py", ROUTER_TEMPLATE), + "plugin": ("plugin.py", PLUGIN_TEMPLATE), + "tests": ("tests.py", TEST_TEMPLATE), +} def create_file(path: Path, content: str) -> None: if path.exists(): - print(f"āš ļø Skip: {path} already exists.") + console.print(f"[yellow]āš ļø Skip:[/yellow] {path} already exists.") return with open(path, "w", encoding="utf-8") as f: f.write(content) - print(f"āœ… Created: {path}") -def init_project(project_name: str) -> None: +def is_zcore_project(path: Path = Path.cwd()) -> bool: + main_py = path / "main.py" + if main_py.exists(): + try: + content = main_py.read_text(encoding="utf-8", errors="ignore") + return "zcore" in content or "Kernel" in content + except Exception: + return False + return False + + +def find_zcore_projects(path: Path = Path.cwd()) -> list[str]: + projects = [] + try: + for item in path.iterdir(): + if item.is_dir() and not item.name.startswith((".", "_", "venv")): + if is_zcore_project(item): + projects.append(item.name) + except Exception: + pass + return sorted(projects) + + +def setup_virtualenv(project_dir: Path, pkg_manager: str = "pip") -> bool: + try: + if pkg_manager == "uv": + subprocess.run(["uv", "venv", ".venv"], cwd=project_dir, check=True, capture_output=True) + subprocess.run(["uv", "pip", "install", "-r", "requirements.txt"], cwd=project_dir, check=True, capture_output=True) + else: + subprocess.run([sys.executable, "-m", "venv", ".venv"], cwd=project_dir, check=True, capture_output=True) + python_bin = project_dir / ".venv" / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") + subprocess.run([str(python_bin), "-m", "pip", "install", "-r", "requirements.txt"], cwd=project_dir, check=True, capture_output=True) + return True + except Exception: + return False + + +def init_project( + project_name: str, + db_driver: str = "sqlite", + install_deps: bool = False, + pkg_manager: str = "pip", +) -> None: project_dir = Path(project_name.lower()) if project_dir.exists(): - print(f"āŒ Error: Directory '{project_dir}' already exists.") + console.print(f"[bold red]āŒ Error:[/bold red] Directory '{project_dir}' already exists.") sys.exit(1) project_dir.mkdir(parents=True) generated_secret = secrets.token_hex(32) + driver_info = DB_DRIVERS.get(db_driver, DB_DRIVERS["sqlite"]) + env_content = ENV_TEMPLATE.format( + project_name=project_name, secret_key=generated_secret + ) + if db_driver != "sqlite": + env_content = env_content.replace( + "DATABASE_URL=sqlite+aiosqlite:///zcore_dev.db", + f"DATABASE_URL={driver_info['url']}", + ) + + requirements_content = REQUIREMENTS_TEMPLATE + driver_info["pkg"] + files_to_create = { "main.py": MAIN_PY_TEMPLATE, - ".env": ENV_TEMPLATE.format( - project_name=project_name, secret_key=generated_secret - ), - "requirements.txt": REQUIREMENTS_TEMPLATE, + ".env": env_content, + "requirements.txt": requirements_content, ".gitignore": GITIGNORE_TEMPLATE, } - print(f"\n🧱 Initializing ZCore Project: '{project_name}'...") - for filename, content in files_to_create.items(): - create_file(project_dir / filename, content) - - db_file = project_dir / "zcore_dev.db" - db_file.touch() - print(f"āœ… Created: {db_file}") - - print(f"\nšŸŽ‰ Project '{project_name}' initialized successfully!") - print( - f"šŸ‘‰ Run: 'cd {project_name}' and run 'python -m zcore.cli startapp ' to generate a plugin!" - ) + with console.status( + f"[bold {ZCORE_PRIMARY}]Scaffolding ZCore project in ./{project_name}...[/bold {ZCORE_PRIMARY}]", + spinner="dots", + ): + time.sleep(0.4) + for filename, content in files_to_create.items(): + create_file(project_dir / filename, content) + + if db_driver == "sqlite": + (project_dir / "zcore_dev.db").touch() + + if install_deps: + with console.status( + f"[bold {ZCORE_PRIMARY}]Setting up .venv & installing dependencies via {pkg_manager}...[/bold {ZCORE_PRIMARY}]", + spinner="dots", + ): + success = setup_virtualenv(project_dir, pkg_manager=pkg_manager) + status_msg = ( + "[dim]│[/dim] [bold green]āœ“[/bold green] Environment & dependencies installed successfully." + if success + else "[dim]│[/dim] [yellow]āš ļø Automated installation failed. Please run pip install manually.[/yellow]" + ) + console.print(status_msg) + + print_step_footer(f"Project '{project_name}' ready!") + + tree = Tree(f"[bold {ZCORE_PRIMARY}]šŸ“ {project_name}/[/bold {ZCORE_PRIMARY}]") + tree.add("šŸ“„ main.py [dim](FastAPI Lifespan & Kernel)[/dim]") + tree.add("šŸ“„ .env [dim](Configured SECRET_KEY & Database URI)[/dim]") + tree.add("šŸ“„ requirements.txt") + tree.add("šŸ“„ .gitignore") + if db_driver == "sqlite": + tree.add("šŸ’¾ zcore_dev.db [dim](Local dev database)[/dim]") + if install_deps and (project_dir / ".venv").exists(): + tree.add("šŸ“¦ .venv/ [dim](Isolated Python Environment)[/dim]") + console.print(tree) + console.print() + + console.print("[bold]Next steps:[/bold]") + console.print(f" [dim]1.[/dim] [{ZCORE_ACCENT}]cd[/{ZCORE_ACCENT}] {project_name}") + if not install_deps: + console.print(f" [dim]2.[/dim] [{ZCORE_ACCENT}]pip install -r requirements.txt[/{ZCORE_ACCENT}]") + else: + activate_cmd = ".venv\\Scripts\\activate" if sys.platform == "win32" else "source .venv/bin/activate" + console.print(f" [dim]2.[/dim] [{ZCORE_ACCENT}]{activate_cmd}[/{ZCORE_ACCENT}]") + console.print(f" [dim]3.[/dim] [{ZCORE_ACCENT}]zc startapp [/{ZCORE_ACCENT}]") + console.print(f" [dim]4.[/dim] [{ZCORE_ACCENT}]zc run[/{ZCORE_ACCENT}]\n") def start_app( - app_name: str, with_template: bool = False, with_test: bool = False + app_name: str, + target_dir: Path | None = None, + with_template: bool = True, + with_test: bool = True, + selected_components: list[str] | None = None, + templated_components: list[str] | None = None, ) -> None: - app_dir = Path(app_name.lower()) + base_dir = target_dir or Path.cwd() + app_dir = base_dir / app_name.lower() if app_dir.exists(): - print(f"āŒ Error: App folder '{app_dir}' already exists.") + console.print(f"[bold red]āŒ Error:[/bold red] App folder '{app_dir}' already exists.") sys.exit(1) app_dir.mkdir(parents=True) @@ -78,65 +203,85 @@ def start_app( "ModelName": model_name, "table_name": table_name, "app_name": table_name, - "project_name": Path(os.getcwd()).name, + "project_name": base_dir.name, } - files_to_create = { - "__init__.py": "", - "models.py": MODEL_TEMPLATE.format(**context) if with_template else "", - "schemas.py": SCHEMA_TEMPLATE.format(**context) if with_template else "", - "repositories.py": REPOSITORY_TEMPLATE.format(**context) - if with_template - else "", - "services.py": SERVICE_TEMPLATE.format(**context) if with_template else "", - "routers.py": ROUTER_TEMPLATE.format(**context) if with_template else "", - "plugin.py": PLUGIN_TEMPLATE.format(**context), - } + files_to_create = {"__init__.py": ""} - if with_test: - files_to_create["tests.py"] = ( - TEST_TEMPLATE.format(**context) if with_template else "" - ) + for comp_key, (filename, raw_template) in COMPONENT_TEMPLATES.items(): + if selected_components is not None and comp_key not in selected_components: + continue + if comp_key == "tests" and not with_test and selected_components is None: + continue - print(f"\nšŸš€ Scaffolding ZCore Domain App: {model_name}...") - for filename, content in files_to_create.items(): - create_file(app_dir / filename, content) + use_template = ( + comp_key in templated_components + if templated_components is not None + else with_template + ) + files_to_create[filename] = raw_template.format(**context) if use_template else "" + + with console.status( + f"[bold {ZCORE_PRIMARY}]Scaffolding ZCore domain module: '{model_name}'...[/bold {ZCORE_PRIMARY}]", + spinner="dots", + ): + time.sleep(0.4) + for filename, content in files_to_create.items(): + create_file(app_dir / filename, content) + + print_step_footer(f"Modular App '{app_name}' created successfully!") + + tree = Tree(f"[bold {ZCORE_PRIMARY}]šŸ“ {app_name}/[/bold {ZCORE_PRIMARY}]") + for filename in sorted(files_to_create.keys()): + tree.add(f"šŸ“„ {filename}") + console.print(tree) + console.print() + + console.print( + Panel.fit( + f"[bold {ZCORE_TEXT}]Register this plugin in your [{ZCORE_ACCENT}]main.py[/{ZCORE_ACCENT}]:[/bold {ZCORE_TEXT}]\n\n" + f"[dim]from[/dim] [{ZCORE_ACCENT}]{app_name}.plugin[/{ZCORE_ACCENT}] [dim]import[/dim] [bold yellow]{model_name}Plugin[/bold yellow]\n" + f"[{ZCORE_ACCENT}]kernel.add_plugin[/{ZCORE_ACCENT}]([bold yellow]{model_name}Plugin[/bold yellow]())", + border_style=ZCORE_PRIMARY, + title="[bold]Plugin Registration[/bold]", + ) + ) + console.print() - print(f"\nšŸŽ‰ Modular App '{app_name}' created successfully!") - print("šŸ‘‰ REGISTER this plugin in your main.py:") - print(f" from {app_name}.plugin import {model_name}Plugin") - print(f" kernel.add_plugin({model_name}Plugin())") +def run_server(project_dir: Path | None = None) -> None: + work_dir = project_dir or Path.cwd() + main_file = work_dir / "main.py" -def run_server() -> None: - if not os.path.exists("main.py"): - print( - "āŒ Error: 'main.py' not found in current directory. Are you in a ZCore project root?" + if not main_file.exists(): + console.print( + f"[bold red]āŒ Error:[/bold red] 'main.py' not found in '{work_dir}'. Are you in a ZCore project root?" ) sys.exit(1) - host = "127.0.0.1" - port = "8000" + host, port = "127.0.0.1", "8000" + env_file = work_dir / ".env" - if os.path.exists(".env"): - with open(".env") as env_file: - for line in env_file: + if env_file.exists(): + with open(env_file) as f: + for line in f: if line.strip() and not line.startswith("#"): parts = line.strip().split("=", 1) if len(parts) == 2: - key, val = parts[0].strip(), parts[1].strip() - if key == "HOST": - host = val - if key == "PORT": - port = val + k, v = parts[0].strip(), parts[1].strip().strip('"\'') + if k == "HOST": + host = v + elif k == "PORT": + port = v - print(f"šŸ“” Starting ZCore Dev Server on {host}:{port}...") + print_step_header(f"Starting ZCore Development Server ({work_dir.name})") + console.print(f"[dim]│[/dim] [dim]Host: {host} | Port: {port} | Reload: Enabled[/dim]") + console.print("[dim]│[/dim]") + print_step_footer(f"Uvicorn running on [bold underline {ZCORE_ACCENT}]http://{host}:{port}[/bold underline {ZCORE_ACCENT}] [dim](Press CTRL+C to quit)[/dim]") env = os.environ.copy() current_pythonpath = env.get("PYTHONPATH", "") - env["PYTHONPATH"] = ( - f".{os.pathsep}{current_pythonpath}" if current_pythonpath else "." - ) + env["PYTHONPATH"] = f"{work_dir!s}{os.pathsep}{current_pythonpath}" if current_pythonpath else str(work_dir) try: subprocess.run( @@ -149,60 +294,44 @@ def run_server() -> None: f"--port={port}", "--reload", ], + cwd=work_dir, env=env, check=True, ) except KeyboardInterrupt: - print("\nšŸ‘‹ Server shutdown cleanly.") + console.print("\n[dim]šŸ‘‹ Server stopped cleanly.[/dim]\n") except Exception as e: - print(f"āŒ Failed to run Uvicorn dev server: {e}") + console.print(f"[bold red]āŒ Failed to run Uvicorn dev server:[/bold red] {e}") -def gen_env(output_file: str = ".env.example", force: bool = False) -> None: - """Generates a template .env file based on the registered Settings class. +def gen_secret() -> None: + print_step_header("Generating Cryptographic Secret Key") + secret = secrets.token_hex(32) + print_step_footer("Generated 64-character SECRET_KEY:") + console.print(Panel(f"[bold {ZCORE_ACCENT}]{secret}[/bold {ZCORE_ACCENT}]", border_style=ZCORE_PRIMARY)) + console.print(f"[{ZCORE_MUTED}]šŸ’” Paste this into your production .env file under SECRET_KEY[/{ZCORE_MUTED}]\n") - This function discovers the active Settings class by trying to import the user's main - module (supporting relative imports by using package context), resolving the settings - instance via IoC container or subclass tracking, and formatting the field default values. - """ - import importlib - cwd = Path.cwd() +def gen_env(output_file: str = ".env.example", force: bool = False, project_dir: Path | None = None) -> None: + cwd = project_dir or Path.cwd() parent_dir = cwd.parent module_name = cwd.name - imported = False if module_name.isidentifier(): if str(parent_dir) not in sys.path: sys.path.insert(0, str(parent_dir)) - try: + with contextlib.suppress(Exception): importlib.import_module(f"{module_name}.main") - imported = True - except Exception: - pass - - if not imported: - if str(cwd) not in sys.path: - sys.path.insert(0, str(cwd)) - - from pydantic_core import PydanticUndefined - from zcore.config import Settings, get_settings + if str(cwd) not in sys.path: + sys.path.insert(0, str(cwd)) subclasses = Settings.__subclasses__() - if subclasses: - settings_class = max(subclasses, key=lambda c: len(c.model_fields)) - else: - try: - settings_class = get_settings().__class__ - except Exception: - settings_class = Settings + settings_class = max(subclasses, key=lambda c: len(c.model_fields)) if subclasses else get_settings().__class__ - out_path = Path(output_file) + out_path = cwd / output_file if not Path(output_file).is_absolute() else Path(output_file) if out_path.exists() and not force: - print( - f"āŒ Error: Output file '{out_path}' already exists. Use --force to overwrite." - ) + console.print(f"[bold red]āŒ Error:[/bold red] Output file '{out_path}' already exists. Use --force to overwrite.") sys.exit(1) env_lines = [] @@ -211,19 +340,12 @@ def gen_env(output_file: str = ".env.example", force: bool = False) -> None: if default_val is PydanticUndefined or default_val is None: env_lines.append(f"{field_name}=") else: - if isinstance(default_val, bool): - env_lines.append(f"{field_name}={default_val!s}") - elif isinstance(default_val, (int, float)): - env_lines.append(f"{field_name}={default_val}") - else: - env_lines.append(f"{field_name}={default_val!s}") + env_lines.append(f"{field_name}={default_val!s}") try: with open(out_path, "w", encoding="utf-8") as f: f.write("\n".join(env_lines) + "\n") - print( - f"āœ… Created: {out_path} based on '{settings_class.__name__}' configuration fields." - ) + print_step_footer(f"Created '{out_path.name}' based on '{settings_class.__name__}' configuration fields.") except Exception as e: - print(f"āŒ Failed to generate env file: {e}") - sys.exit(1) + console.print(f"[bold red]āŒ Failed to generate env file:[/bold red] {e}") + sys.exit(1) \ No newline at end of file diff --git a/src/zcore/cli/templates.py b/src/zcore/cli/templates.py index 09d2a8d..008b88c 100644 --- a/src/zcore/cli/templates.py +++ b/src/zcore/cli/templates.py @@ -49,15 +49,27 @@ async def root(): } """ -ENV_TEMPLATE = """PYTHONPATH=. -DEBUG=True +ENV_TEMPLATE = """# --- Local Runner / Infrastructure --- +PYTHONPATH=. +HOST=127.0.0.1 +PORT=8000 + +# --- ZCore Application Settings --- PROJECT_NAME="{project_name}" +DEBUG=True + DATABASE_URL=sqlite+aiosqlite:///zcore_dev.db +DATABASE_TEST_URL=sqlite+aiosqlite:///zcore_test.db +POOL_SIZE=5 +MAX_OVERFLOW=10 + SECRET_KEY="{secret_key}" ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=30 -PORT=8000 -HOST=127.0.0.1 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +STORAGE_PATH=./storage +REDIS_URL= """ REQUIREMENTS_TEMPLATE = """fastapi-zcore-framework[all] diff --git a/src/zcore/cli/ui.py b/src/zcore/cli/ui.py new file mode 100644 index 0000000..e9a2b1a --- /dev/null +++ b/src/zcore/cli/ui.py @@ -0,0 +1,44 @@ +from questionary import Style +from rich.console import Console + +console = Console() + +ZCORE_PRIMARY = "#10b981" +ZCORE_SECONDARY = "#059669" +ZCORE_ACCENT = "#06b6d4" +ZCORE_MUTED = "#64748b" +ZCORE_TEXT = "#f8fafc" + +custom_style = Style([ + ("qmark", f"fg:{ZCORE_PRIMARY} bold"), + ("question", f"fg:{ZCORE_TEXT} bold"), + ("answer", f"fg:{ZCORE_PRIMARY} bold"), + ("pointer", f"fg:{ZCORE_PRIMARY} bold"), + ("highlighted", f"fg:{ZCORE_PRIMARY} bold"), + ("selected", f"fg:{ZCORE_PRIMARY}"), + ("instruction", f"fg:{ZCORE_MUTED} italic"), +]) + + +def print_banner() -> None: + console.print() + console.print( + f"[bold {ZCORE_PRIMARY}]⚔ ZCore Framework[/bold {ZCORE_PRIMARY}] " + f"[dim white]v0.1.0-Beta[/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}]") + console.print() + + +def print_step_header(title: str) -> None: + console.print(f"[dim]ā”Œ[/dim] [bold {ZCORE_PRIMARY}]{title}[/bold {ZCORE_PRIMARY}]") + console.print("[dim]│[/dim]") + + +def print_step_footer(message: str) -> None: + console.print(f"[dim]ā””[/dim] [bold {ZCORE_PRIMARY}]{message}[/bold {ZCORE_PRIMARY}]\n") + + +def print_cancelled() -> None: + console.print("[dim]ā””[/dim] [red]Cancelled.[/red]\n") \ No newline at end of file diff --git a/tests/test_cli.py b/tests/test_cli.py index bd03dac..1dc01a7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,6 +6,7 @@ import pytest from zcore.cli import main +from zcore.cli.commands import find_zcore_projects, is_zcore_project @pytest.fixture @@ -13,16 +14,20 @@ def run_in_tmp_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: monkeypatch.chdir(tmp_path) return tmp_path + @pytest.mark.parametrize( - "project_name", + "project_name, db_driver", [ - "core_api", - "enterprise_service", - ] + ("core_api", "sqlite"), + ("enterprise_service", "postgres"), + ("mysql_service", "mysql"), + ], ) -def test_cli_init_command(run_in_tmp_path: Path, project_name: str) -> None: - test_args = ["zc", "init", project_name] - +def test_cli_init_command_non_interactive( + run_in_tmp_path: Path, project_name: str, db_driver: str +) -> None: + test_args = ["zc", "init", project_name, "--db", db_driver, "-y"] + with patch.object(sys, "argv", test_args): main() @@ -33,36 +38,59 @@ def test_cli_init_command(run_in_tmp_path: Path, project_name: str) -> None: for file in expected_files: assert (project_dir / file).is_file() - env_content = (project_dir / ".env").read_text() + env_content = (project_dir / ".env").read_text(encoding="utf-8") assert f'PROJECT_NAME="{project_name}"' in env_content secret_key_match = re.search(r'SECRET_KEY="([a-f0-9]{64})"', env_content) assert secret_key_match is not None + reqs_content = (project_dir / "requirements.txt").read_text(encoding="utf-8") + if db_driver == "postgres": + assert "asyncpg" in reqs_content + elif db_driver == "mysql": + assert "aiomysql" in reqs_content + + +def test_cli_init_collision(run_in_tmp_path: Path) -> None: + project_name = "collision_project" + project_dir = run_in_tmp_path / project_name + project_dir.mkdir() + test_args = ["zc", "init", project_name, "-y"] + + with patch.object(sys, "argv", test_args): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + + @pytest.mark.parametrize( - "app_name, with_template, expected_pascal_name", + "app_name, with_template, with_test, expected_pascal_name", [ - ("payment_gateway", True, "PaymentGateway"), - ("order_management", False, "OrderManagement"), - ] + ("payment_gateway", True, True, "PaymentGateway"), + ("order_management", False, True, "OrderManagement"), + ("auth_service", True, False, "AuthService"), + ], ) def test_cli_startapp_scaffolding( run_in_tmp_path: Path, monkeypatch: pytest.MonkeyPatch, app_name: str, with_template: bool, - expected_pascal_name: str + with_test: bool, + expected_pascal_name: str, ) -> None: project_name = "test_project" - init_args = ["zc", "init", project_name] + init_args = ["zc", "init", project_name, "-y"] with patch.object(sys, "argv", init_args): main() monkeypatch.chdir(run_in_tmp_path / project_name) - startapp_args = ["zc", "startapp", app_name] - if with_template: - startapp_args.append("-t") + startapp_args = ["zc", "startapp", app_name, "-y"] + if not with_template: + startapp_args.append("--no-template") + if not with_test: + startapp_args.append("--no-test") with patch.object(sys, "argv", startapp_args): main() @@ -82,135 +110,93 @@ def test_cli_startapp_scaffolding( for file in expected_files: assert (app_dir / file).is_file() - plugin_content = (app_dir / "plugin.py").read_text() - assert f"class {expected_pascal_name}Plugin(Plugin):" in plugin_content + if with_test: + assert (app_dir / "tests.py").is_file() + else: + assert not (app_dir / "tests.py").exists() + + plugin_content = (app_dir / "plugin.py").read_text(encoding="utf-8") + models_content = (app_dir / "models.py").read_text(encoding="utf-8") - models_content = (app_dir / "models.py").read_text() if with_template: + assert f"class {expected_pascal_name}Plugin(Plugin):" in plugin_content assert f"class {expected_pascal_name}(Base):" in models_content assert f'__tablename__ = "{app_name}"' in models_content else: assert models_content == "" -@pytest.mark.parametrize( - "command, invalid_name", - [ - ("init", "123_invalid_project"), - ("init", "project-with-hyphen"), - ("init", "project.domain"), - ("startapp", "123_invalid_app"), - ("startapp", "app-with-hyphen"), - ("startapp", "app.class"), - ] -) -def test_cli_invalid_module_names(run_in_tmp_path: Path, command: str, invalid_name: str) -> None: - test_args = ["zc", command, invalid_name] - - with patch.object(sys, "argv", test_args): - with pytest.raises(SystemExit) as exc_info: - main() - assert exc_info.value.code == 1 - -def test_cli_version_command(capsys) -> None: - 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 - captured = capsys.readouterr() - assert "ZCore Framework - Version 0.1.0-Beta" in captured.out - -def test_cli_help_command(capsys) -> None: - test_args = ["zc"] - with patch.object(sys, "argv", test_args): - with pytest.raises(SystemExit) as exc_info: - main() - assert exc_info.value.code == 0 - captured = capsys.readouterr() - assert "Available Framework Orchestration Commands" in captured.out - -def test_cli_init_collision(run_in_tmp_path: Path) -> None: - project_name = "collision_project" - project_dir = run_in_tmp_path / project_name - project_dir.mkdir() - test_args = ["zc", "init", project_name] - with patch.object(sys, "argv", test_args): - with pytest.raises(SystemExit) as exc_info: - main() - assert exc_info.value.code == 1 def test_cli_startapp_collision(run_in_tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: project_name = "test_project" - init_args = ["zc", "init", project_name] + init_args = ["zc", "init", project_name, "-y"] with patch.object(sys, "argv", init_args): main() + monkeypatch.chdir(run_in_tmp_path / project_name) app_name = "collision_app" app_dir = run_in_tmp_path / project_name / app_name app_dir.mkdir() - startapp_args = ["zc", "startapp", app_name] + + startapp_args = ["zc", "startapp", app_name, "-y"] with patch.object(sys, "argv", startapp_args): with pytest.raises(SystemExit) as exc_info: main() assert exc_info.value.code == 1 -@pytest.mark.parametrize("with_template", [True, False]) -def test_cli_startapp_with_test( - run_in_tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - with_template: bool -) -> None: - project_name = "test_project" - init_args = ["zc", "init", project_name] - with patch.object(sys, "argv", init_args): - main() - monkeypatch.chdir(run_in_tmp_path / project_name) - app_name = "test_app" - startapp_args = ["zc", "startapp", app_name, "--test"] - if with_template: - startapp_args.append("-t") - with patch.object(sys, "argv", startapp_args): + +def test_cli_version_command() -> None: + 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 + + +def test_cli_gensecret(capsys: pytest.CaptureFixture) -> None: + test_args = ["zc", "gensecret"] + with patch.object(sys, "argv", test_args): main() - app_dir = run_in_tmp_path / project_name / app_name - assert (app_dir / "tests.py").is_file() - test_content = (app_dir / "tests.py").read_text() - if with_template: - assert "test_get_test_app_list" in test_content - else: - assert test_content == "" + captured = capsys.readouterr() + secret_match = re.search(r"([a-f0-9]{64})", captured.out) + assert secret_match is not None + def test_cli_genenv_default(run_in_tmp_path: Path) -> None: test_args = ["zc", "genenv"] with patch.object(sys, "argv", test_args): main() assert (run_in_tmp_path / ".env.example").is_file() - content = (run_in_tmp_path / ".env.example").read_text() + content = (run_in_tmp_path / ".env.example").read_text(encoding="utf-8") assert "DATABASE_URL=" in content assert "DEBUG=True" in content + def test_cli_genenv_custom_output(run_in_tmp_path: Path) -> None: test_args = ["zc", "genenv", "-o", "custom.env"] with patch.object(sys, "argv", test_args): main() assert (run_in_tmp_path / "custom.env").is_file() - content = (run_in_tmp_path / "custom.env").read_text() + content = (run_in_tmp_path / "custom.env").read_text(encoding="utf-8") assert "DATABASE_URL=" in content + def test_cli_genenv_collision_and_force(run_in_tmp_path: Path) -> None: existing_file = run_in_tmp_path / ".env.example" - existing_file.write_text("old_content") + existing_file.write_text("old_content", encoding="utf-8") test_args = ["zc", "genenv"] + with patch.object(sys, "argv", test_args): with pytest.raises(SystemExit) as exc_info: main() assert exc_info.value.code == 1 - assert existing_file.read_text() == "old_content" - + assert existing_file.read_text(encoding="utf-8") == "old_content" + force_args = ["zc", "genenv", "--force"] with patch.object(sys, "argv", force_args): main() - assert existing_file.read_text() != "old_content" - assert "DATABASE_URL=" in existing_file.read_text() + assert existing_file.read_text(encoding="utf-8") != "old_content" + assert "DATABASE_URL=" in existing_file.read_text(encoding="utf-8") + def test_cli_run_outside_project_root(run_in_tmp_path: Path) -> None: test_args = ["zc", "run"] @@ -219,10 +205,12 @@ def test_cli_run_outside_project_root(run_in_tmp_path: Path) -> None: main() assert exc_info.value.code == 1 + def test_cli_run_with_uvicorn_mock(run_in_tmp_path: Path) -> None: (run_in_tmp_path / "main.py").touch() - (run_in_tmp_path / ".env").write_text("HOST=127.0.0.1\nPORT=8080\n") + (run_in_tmp_path / ".env").write_text("HOST=127.0.0.1\nPORT=8080\n", encoding="utf-8") test_args = ["zc", "run"] + with patch("subprocess.run") as mock_run, patch.object(sys, "argv", test_args): main() mock_run.assert_called_once() @@ -235,12 +223,28 @@ def test_cli_run_with_uvicorn_mock(run_in_tmp_path: Path) -> None: assert any("--reload" in item for item in cmd) assert "PYTHONPATH" in kwargs.get("env", {}) -def test_cli_gensecret(capsys) -> None: - test_args = ["zc", "gensecret"] - with patch.object(sys, "argv", test_args): - main() - captured = capsys.readouterr() - assert "Generated Cryptographic Secret Key:" in captured.out - secret = captured.out.splitlines()[-1].strip() - assert len(secret) == 64 - assert re.match(r"^[a-f0-9]{64}$", secret) is not None \ No newline at end of file + +def test_project_detection_helpers(run_in_tmp_path: Path) -> None: + assert not is_zcore_project(run_in_tmp_path) + assert find_zcore_projects(run_in_tmp_path) == [] + + p1 = run_in_tmp_path / "service_a" + p1.mkdir() + (p1 / "main.py").write_text("from zcore import Kernel\n", encoding="utf-8") + + p2 = run_in_tmp_path / "service_b" + p2.mkdir() + (p2 / "main.py").write_text("import fastapi\n", encoding="utf-8") + + assert is_zcore_project(p1) + assert not is_zcore_project(p2) + assert find_zcore_projects(run_in_tmp_path) == ["service_a"] + + +def test_interactive_dashboard_cancel(run_in_tmp_path: Path) -> None: + test_args = ["zc"] + with patch("questionary.select") as mock_select, patch.object(sys, "argv", test_args): + mock_select.return_value.ask.return_value = "exit" + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 \ No newline at end of file From 34751e5243128431728bcf42cdbf2894ebc75de7 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Mon, 24 Aug 2026 12:38:22 +0330 Subject: [PATCH 16/19] chore(project): modernize project metadata and development configuration - update project ownership and maintainer metadata - add Python 3.13 support - add PostgreSQL and MySQL dependency groups - expand development dependencies - update Hatch, Ruff, and pytest documentation - refresh test architecture and contribution guidelines - update security documentation references - add project contact and copyright information --- CODE_OF_CONDUCT.md | 6 +++--- CONTRIBUTING.md | 52 +++++++++++++++++++++++++++++----------------- LICENSE | 2 +- SECURITY.md | 10 ++++----- pyproject.toml | 27 +++++++++++++++++++++--- 5 files changed, 66 insertions(+), 31 deletions(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 18c9147..7981471 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -60,7 +60,7 @@ representative at an online or offline event. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at -. +alialfostovar@gmail.com. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the @@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an +standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within @@ -125,4 +125,4 @@ enforcement ladder](https://github.com/mozilla/diversity). For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. +https://www.contributor-covenant.org/translations. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf991cd..2b69145 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,19 +61,30 @@ pip install -e ".[all,dev]" We aim for high test coverage on core architectural units. ZCore uses `pytest` and `pytest-asyncio` for asynchronous test suites. ### Test Architecture -ZCore's codebase is highly modular. To ensure that components like the Kernel, Plugins, Dependency Injection (DI), Unit of Work (UOW), Router, and Middleware interact correctly, we organize our tests as follows: +ZCore's tests are organized modularly across architectural domains: ```text tests/ -ā”œā”€ā”€ unit/ # Tests for isolated, single architectural units with mocks -│ ā”œā”€ā”€ test_repository.py -│ └── test_zchema.py -ā”œā”€ā”€ integration/ # Tests verifying interactions between modules -│ ā”œā”€ā”€ test_kernel_startup.py -│ ā”œā”€ā”€ test_plugin_loading.py -│ └── test_request_scope.py -└── e2e/ # Full end-to-end request/response flows using TestClient - └── test_crud_flow.py +ā”œā”€ā”€ test_cache.py +ā”œā”€ā”€ test_cli.py +ā”œā”€ā”€ test_context.py +ā”œā”€ā”€ test_db_lifecycle.py +ā”œā”€ā”€ test_di.py +ā”œā”€ā”€ test_events.py +ā”œā”€ā”€ test_exceptions.py +ā”œā”€ā”€ test_kernel.py +ā”œā”€ā”€ test_logging.py +ā”œā”€ā”€ test_pagination.py +ā”œā”€ā”€ test_repository.py +ā”œā”€ā”€ test_search.py +ā”œā”€ā”€ test_security.py +ā”œā”€ā”€ test_service.py +ā”œā”€ā”€ test_soft_delete.py +ā”œā”€ā”€ test_storage.py +ā”œā”€ā”€ test_streams.py +ā”œā”€ā”€ test_testing_subsystem.py +ā”œā”€ā”€ test_utils.py +└── test_web.py ``` ### Running Tests Locally @@ -84,11 +95,11 @@ pytest If you prefer to run tests inside Hatch-isolated environments: ```bash -hatch run test +hatch test ``` ### Writing Tests -- **Asynchronous Tests:** Ensure your async tests are decorated with `@pytest.mark.asyncio`. +- **Asynchronous Tests:** Ensure your async tests are decorated with `@pytest.mark.anyio` or `@pytest.mark.asyncio`. - **Isolation:** Tests should not leave behind lingering SQLite databases, active sessions, or running background threads. - **Mocks:** When writing tests that require external services like Redis, implement mock fallbacks where appropriate to ensure the test suite can run fully offline. @@ -107,8 +118,8 @@ mypy src/ ### 2. Linting & Formatting Ensure your code is clean, PEP-8 compliant, and structured consistently. We recommend using `ruff` for formatting and linting: ```bash -ruff check src/ -ruff format src/ +ruff check . +ruff format --check . ``` --- @@ -124,14 +135,17 @@ ZCore uses the **Conventional Commits** specification to automate our changelog Common types include: - `feat`: A new feature (corresponds to a minor version bump). - `fix`: A bug fix (corresponds to a patch version bump). +- `perf`: A code change that improves performance. - `docs`: Documentation updates. +- `style`: Changes that do not affect the meaning of the code (white-space, formatting, etc.). - `refactor`: Code changes that neither fix a bug nor add a feature. - `test`: Adding or correcting tests. +- `build`: Changes that affect the build system or external dependencies. - `ci`: Changes to our CI/CD workflows or scripts. *Examples:* -- `feat(di): add redis plugin support` -- `fix(uow): resolve transaction rollback issue on async commit` +- `feat(di): add background_scope and background_task decorator` +- `perf(db): optimize create_multi using insert returning clause` - `docs(zchema): update guide for dynamic schema pruning` --- @@ -148,12 +162,12 @@ flowchart LR D --> E[Submit PR] ``` -1. **Branch Naming:** Use descriptive branch names (e.g., `feature/add-s3-provider` or `bugfix/fix-uow-flush`). +1. **Branch Naming:** Use descriptive branch names (e.g., `feat/add-s3-provider` or `fix/uow-flush-isolation`). 2. **Atomic Commits:** Keep your commits focused on single logical changes and adhere to the conventional commits schema. 3. **Documentation:** If you are adding a new feature or modifying an existing protocol, you **must** update the corresponding documentation page under the `docs/` folder. ### šŸ›”ļø CI/CD & Branch Protection -Our repository has strict branch protection rules configured on `master` and `develop`. +Our repository has strict branch protection rules configured on `main` and `develop`. - **Mandatory CI Checks:** Your Pull Request cannot be merged unless all status checks (Linting with Ruff, Type-checking with Mypy, and Test Suites with Pytest) pass successfully in our GitHub Actions pipeline. - **No Direct Pushes:** All code changes must go through a Pull Request. Direct pushes to protected branches are blocked. @@ -164,4 +178,4 @@ Our repository has strict branch protection rules configured on `master` and `de By contributing to ZCore, you agree that your contributions will be licensed under the project's **Apache License 2.0**. - **No CLA Required:** We do not currently require a signed Contributor License Agreement (CLA) for individual contributions. -- **Certificate of Origin:** By submitting a Pull Request, you assert that you have the right to submit this code and that it conforms to an open-source contribution under the Apache 2.0 terms. +- **Certificate of Origin:** By submitting a Pull Request, you assert that you have the right to submit this code and that it conforms to an open-source contribution under the Apache 2.0 terms. \ No newline at end of file diff --git a/LICENSE b/LICENSE index 261eeb9..ac5c6d6 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2024-2026 Ali Alf Ostovar / Baseryn Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/SECURITY.md b/SECURITY.md index 3183949..6e6454c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security Policy -We take the security of ZCore, and the applications built on top of it, extremely seriously. Security is a core pillar of ZCore's design philosophy, integrated deeply into its architecture [security/jwt.py, security/hashing.py] rather than treated as an afterthought. +We take the security of ZCore, and the applications built on top of it, extremely seriously. Security is a core pillar of ZCore's design philosophy, integrated deeply into its architecture (`zcore/security/security.py`, `zcore/web/projection.py`) rather than treated as an afterthought. If you believe you have found a security vulnerability in ZCore, please read this document to learn how to report it responsibly. @@ -87,9 +87,9 @@ The following are generally **not considered** security vulnerabilities in ZCore ## Hardened Defaults in ZCore -ZCore is built with strict, "secure-by-default" configurations [security/jwt.py, storage/local.py]: -- **Production Secret Guard:** The framework will raise a fatal traceback and refuse to start in a production environment if the default fallback `SECRET_KEY` is detected [security/jwt.py]. -- **Argon2id default configuration:** We enforce high-entropy password hashing out-of-the-box [security/hashing.py]. -- **Path Traversal Protection:** The local storage provider strictly verifies resolved filesystem paths before running write or unlink operations [storage/local.py]. +ZCore is built with strict, "secure-by-default" configurations: +- **Production Secret Guard:** The framework will raise a fatal traceback and refuse to start in a production environment if the default fallback `SECRET_KEY` is detected (`zcore/security/security.py`). +- **Argon2id default configuration:** We enforce high-entropy password hashing out-of-the-box (`zcore/security/security.py`). +- **Path Traversal Protection:** The local storage provider strictly verifies resolved filesystem paths before running write or unlink operations (`zcore/storage/local.py`). *Note: ZCore only fails-fast (crashes) on fatal security configurations. Missing optional configuration elements (e.g., optional caching backends) will only raise non-fatal warnings.* \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 46d8514..0634e4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,12 +12,16 @@ license = { text = "Apache License 2.0" } authors = [ { name = "Ali Alf Ostovar", email = "240363843+alialfostovar@users.noreply.github.com" } ] +maintainers = [ + { name = "Baseryn" } +] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Framework :: FastAPI", "Topic :: Software Development :: Libraries :: Application Frameworks", ] @@ -39,6 +43,14 @@ db = [ "sqlalchemy[asyncio]>=2.0.28", "aiosqlite>=0.20.0", ] +postgres = [ + "sqlalchemy[asyncio]>=2.0.28", + "asyncpg>=0.29.0", +] +mysql = [ + "sqlalchemy[asyncio]>=2.0.28", + "aiomysql>=0.2.0", +] redis = [ "redis>=5.0.3", ] @@ -52,9 +64,18 @@ crypto = [ "argon2-cffi>=23.1.0", "pyjwt>=2.8.0", ] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "httpx>=0.27.0", + "mypy>=1.9.0", + "ruff>=0.3.0", +] all = [ "sqlalchemy[asyncio]>=2.0.28", "aiosqlite>=0.20.0", + "asyncpg>=0.29.0", + "aiomysql>=0.2.0", "redis>=5.0.3", "aiofiles>=23.2.1", "argon2-cffi>=23.1.0", @@ -83,11 +104,11 @@ testpaths = ["tests"] [tool.hatch.envs.hatch-test] extra-dependencies = [ - 'pytest-asyncio', - 'httpx' + "pytest-asyncio", + "httpx" ] features = [ - 'all' + "all" ] [tool.ruff] From 751df7df70117b981fb763c6a89541a7ea2a376a Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Mon, 24 Aug 2026 12:47:14 +0330 Subject: [PATCH 17/19] docs(readme): document interactive CLI and scaffolding workflow - update CLI feature descriptions and project scaffolding examples - document interactive TUI and context-aware project detection - add database driver and virtual environment setup details - document granular domain layer scaffolding modes - add interactive and non-interactive CLI command reference - update endpoint count and project attribution --- README.md | 64 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index dcb6603..da22c8d 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ While FastAPI provides a high-performance engine for HTTP, it leaves the archite - **⚔ Scoped Dependency Injection** — High-performance constructor auto-wiring for Singleton, Transient, and request-scoped dependencies. - **šŸ—ļø Modular Plugin Architecture** — Organize business domains into decoupled plugins with topological dependency ordering. - **šŸ” Secure Dynamic Search Engine** — Nested JSON filters, eager loading, cursor/offset pagination, and column-level access controls. -- **šŸ“¦ Zero-Boilerplate CLI** — From `pip install` to running secure CRUD endpoints in under two minutes. +- **šŸ“¦ Interactive TUI & Scaffolding Engine** — Context-aware terminal dashboard, lightning-fast `uv` virtualenv orchestration, and granular layer-by-layer domain generators. --- @@ -46,13 +46,13 @@ While FastAPI provides a high-performance engine for HTTP, it leaves the archite | Concern | Raw FastAPI | With ZCore | |---------|-------------|------------| -| **Endpoint Scaffolding** | Manually write 7+ routes and handlers per model | One `BaseRouter` class $\rightarrow$ 7 secure endpoints out-of-the-box | +| **Endpoint Scaffolding** | Manually write 7+ routes and handlers per model | One `BaseRouter` class → 7 secure endpoints out-of-the-box | | **Data Leakage** | Multiple Pydantic models per role; manual conditionals | `Zchema` auto-prunes restricted fields per active context | | **Database Transactions** | Scattered `commit()` / `rollback()` calls | `UnitOfWork` guarantees atomicity + post-commit domain events | | **Dependency Wiring** | Deeply nested, verbose `Depends()` parameter chains | Clean constructor auto-wiring via IoC container + `Inject[T]` | | **Search & Pagination** | Hand-crafted SQL parsing per endpoint | Declarative JSON filters + keyset cursor and offset pagination | -| **Project Layout** | No standard; every team reinvents the wheel | `zc init` + `zc startapp` — decoupled domain modularity | -| **Startup Orchestration** | Fragile `@app.on_event` chains | `Plugin` protocol with dependency DAG $\rightarrow$ topological sorting | +| **Project Tooling & Layout** | Manual folder creation, fragmented glue scripts | `zc` interactive TUI with auto `uv` setup & granular layer picking | +| **Startup Orchestration** | Fragile `@app.on_event` chains | `Plugin` protocol with dependency DAG → topological sorting | --- @@ -66,9 +66,16 @@ pip install fastapi-zcore-framework[all] ### 2. Scaffold +Launch the interactive wizard or run directly: + ```bash +# Interactive mode (prompts for DB driver, uv/.venv installation, etc.) zc init my_app && cd my_app -zc startapp tasks --template +zc startapp tasks + +# Or instant non-interactive scaffolding with defaults +zc init my_app -y && cd my_app +zc startapp tasks -y ``` ### 3. Define @@ -94,7 +101,7 @@ class Task(Base): zc run ``` -Your API is live at **`http://127.0.0.1:8000`** with 6 CRUD endpoints and dynamic search ready. +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) @@ -209,19 +216,42 @@ A dynamic query builder that translates nested JSON filters into safe SQLAlchemy
-šŸ“¦ CLI Scaffolding +šŸ“¦ Interactive TUI & Scaffolding Engine
-Stop writing boilerplate. The `zc` CLI generates consistent, production-ready domain modules and environment templates with a single command. +The `zc` CLI gives you a rich, interactive Terminal User Interface (TUI) powered by Questionary and Rich. It features: + +* **Workspace & Context Awareness:** Detects whether you are inside an active project, in a root workspace containing multiple services, or starting fresh. +* **Automated `uv` / `.venv` Setup:** Initializes isolated virtual environments and installs dependencies automatically using `uv` (recommended) or standard `pip`. +* **Multi-Engine Driver Bootstrapping:** Configures SQLite (`aiosqlite`), PostgreSQL (`asyncpg`), or MySQL (`aiomysql`) in `.env` and `requirements.txt` out-of-the-box. +* **Granular Architectural Scaffolding:** Generates modular domain apps in 3 modes (*Full Boilerplate*, *Clean/Blank*, or *Custom Layer Selection* across Models, Schemas, Repositories, Services, Routers, Plugins, and Pytest suites). + +```text +$ zc + +⚔ ZCore Framework v0.1.0-Beta • Modern Modular Monolith + FastAPI • SQLAlchemy 2.0 • Pydantic V2 + +? What framework task would you like to perform? šŸ“¦ init — Scaffold a new full ZCore project +ā”Œ Initialize New ZCore Project +│ +? Project directory name: core_api +? Select primary database driver: PostgreSQL (asyncpg) — Production Ready +? Initialize virtual environment (.venv) and install dependencies? Yes (uv) +│ āœ“ Environment & dependencies installed successfully. +ā”” Project 'core_api' ready! +``` + +#### CLI Command Reference -| Command | Purpose | -|---------|---------| -| `zc init ` | Create a new project with settings, `.env`, and `main.py` | -| `zc startapp ` | Scaffold an empty domain module | -| `zc startapp --template` | Scaffold with full Model, Schema, Repo, Service, Router, and Plugin boilerplate | -| `zc run` | Launch Uvicorn with auto-reload and environment-aware config | -| `zc gensecret` | Generate a 64-character cryptographically secure secret key | -| `zc genenv` | Introspect registered `Settings` classes and scaffold `.env.example` | +| Command | Interactive | Non-Interactive Flags | Purpose | +|---------|:-----------:|-----------------------|---------| +| `zc` | āœ… | — | Launch interactive orchestration dashboard | +| `zc init [name]` | āœ… | `--db [sqlite\|postgres\|mysql]`, `-y` | Bootstrap a new project with settings, `.env`, and dependencies | +| `zc startapp [name]` | āœ… | `--template / --no-template`, `--test / --no-test`, `-y` | Scaffold domain modules with granular layer selection | +| `zc run` | āœ… | — | Launch local Uvicorn dev server with auto-reload | +| `zc gensecret` | — | — | Generate a 64-character cryptographically secure `SECRET_KEY` | +| `zc genenv` | āœ… | `-o `, `-f / --force` | Introspect active `Settings` classes and scaffold `.env.example` |
--- @@ -257,5 +287,5 @@ See [LICENSE](https://github.com/Baseryn/zcore/blob/master/LICENSE) for details. ---

- Built with ā˜• and architectural rigor by Ali Alf Ostovar. + Built with ā˜• and architectural rigor by Ali Alf Ostovar / Baseryn.

\ No newline at end of file From 1d19be909494a47ea4b4733cde67998d11575091 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Mon, 24 Aug 2026 12:47:38 +0330 Subject: [PATCH 18/19] docs(readme): include dev dependencies in local setup --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index da22c8d..0f26614 100644 --- a/README.md +++ b/README.md @@ -275,7 +275,7 @@ Contributions are welcome! Please read our guidelines before submitting a PR. - **Issues:** Bug reports and feature requests via [GitHub Issues](https://github.com/Baseryn/zcore/issues) - **PRs:** Open a pull request with a clear description of the change -- **Local Setup:** `pip install -e ".[all]"` and run `hatch test` to verify +- **Local Setup:** `pip install -e ".[all,dev]"` and run `hatch test` to verify --- From ff35dcd9fe5b3409b205fd839c31bfa434d02df0 Mon Sep 17 00:00:00 2001 From: alialfostovar Date: Fri, 28 Aug 2026 04:50:04 -0700 Subject: [PATCH 19/19] fix(cli): align scaffolded repository and service templates with single-generic API signature --- src/zcore/cli/templates.py | 8 +++----- tests/test_cli.py | 20 +++++++++++++++++++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/zcore/cli/templates.py b/src/zcore/cli/templates.py index 008b88c..a611828 100644 --- a/src/zcore/cli/templates.py +++ b/src/zcore/cli/templates.py @@ -161,19 +161,17 @@ class {ModelName}Response({ModelName}Base): from sqlalchemy.ext.asyncio import AsyncSession from .models import {ModelName} -from .schemas import {ModelName}Create, {ModelName}Update -class {ModelName}Repository(BaseRepository[{ModelName}, {ModelName}Create, {ModelName}Update]): +class {ModelName}Repository(BaseRepository[{ModelName}]): def __init__(self, db: AsyncSession): super().__init__(model={ModelName}, db=db) """ SERVICE_TEMPLATE = """from zcore import BaseService from .models import {ModelName} -from .schemas import {ModelName}Create, {ModelName}Update from .repositories import {ModelName}Repository -class {ModelName}Service(BaseService[{ModelName}, {ModelName}Create, {ModelName}Update]): +class {ModelName}Service(BaseService[{ModelName}]): def __init__(self, repository: {ModelName}Repository): super().__init__(model={ModelName}, repository=repository) """ @@ -256,4 +254,4 @@ async def test_get_{app_name}_list(): async with ZTestClient(app, user_id=uid, scopes=["{app_name}:listview"]) as client: response = await client.get("/{app_name}/") assert response.status_code == 200 -""" +""" \ No newline at end of file diff --git a/tests/test_cli.py b/tests/test_cli.py index 1dc01a7..7d3c30c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -247,4 +247,22 @@ def test_interactive_dashboard_cancel(run_in_tmp_path: Path) -> None: mock_select.return_value.ask.return_value = "exit" with pytest.raises(SystemExit) as exc_info: main() - assert exc_info.value.code == 0 \ No newline at end of file + assert exc_info.value.code == 0 + + +def test_scaffolded_templates_are_valid_python( + run_in_tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + project_name = "syntax_check_project" + with patch.object(sys, "argv", ["zc", "init", project_name, "-y"]): + main() + + monkeypatch.chdir(run_in_tmp_path / project_name) + with patch.object(sys, "argv", ["zc", "startapp", "billing_domain", "-y"]): + main() + + app_dir = run_in_tmp_path / project_name / "billing_domain" + 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