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/README.md b/README.md
index dcb6603..0f26614 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
---
@@ -245,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
---
@@ -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
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 282974f..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",
]
@@ -30,6 +34,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]
@@ -37,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",
]
@@ -50,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",
@@ -81,11 +104,11 @@ testpaths = ["tests"]
[tool.hatch.envs.hatch-test]
extra-dependencies = [
- 'pytest-asyncio',
- 'httpx'
+ "pytest-asyncio",
+ "httpx"
]
features = [
- 'all'
+ "all"
]
[tool.ruff]
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/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..a611828 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]
@@ -149,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)
"""
@@ -244,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/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/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/repository.py b/src/zcore/db/repository.py
index e1c0a42..8e160d5 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, delete, func, insert, inspect, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import load_only
from sqlalchemy.orm.interfaces import ExecutableOption
@@ -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
@@ -258,13 +261,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
@@ -295,35 +296,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 []
- 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 = [
+ {
+ **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()
- if refresh:
- for record in updated_records:
- await self.db.refresh(record)
- return updated_records
+
+ 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.
@@ -353,11 +357,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]):
diff --git a/src/zcore/db/search.py b/src/zcore/db/search.py
index fbae574..b273edf 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", "startswith", "endswith", "between", "or", "and"
]
value: Any | None = None
items: list[FilterItem] | None = None
@@ -104,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]
@@ -158,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):
@@ -168,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] = []
@@ -191,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):
@@ -422,6 +419,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 == "startswith":
+ escaped_value = self._escape_like_wildcards(value)
+ return col.ilike(f"{escaped_value}%", escape="\\")
+
+ if op == "endswith":
+ escaped_value = self._escape_like_wildcards(value)
+ return col.ilike(f"%{escaped_value}", escape="\\")
+
+ if op == "contains":
+ if hasattr(col.type, "python_type") and col.type.python_type is str:
+ escaped_value = self._escape_like_wildcards(value)
+ return col.ilike(f"%{escaped_value}%", escape="\\")
+ return col.contains(value)
+
+ if op == "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":
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
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"
]
diff --git a/src/zcore/kernel/di.py b/src/zcore/kernel/di.py
index 734ba2e..480d3a1 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,
@@ -18,7 +21,9 @@
get_type_hints,
)
+from anyio import to_thread
from fastapi import Depends
+from sqlalchemy.ext.asyncio import AsyncSession
T = TypeVar("T")
@@ -304,3 +309,98 @@ 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 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, 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)
+ 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.
+
+ 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[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 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:
+ async with background_scope():
+ final_kwargs = _resolve_injections(args, kwargs)
+ return await func(*args, **final_kwargs)
+
+ return async_wrapper
+ else:
+ @functools.wraps(func)
+ async def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
+ async with background_scope():
+ final_kwargs = _resolve_injections(args, 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/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..7d3c30c 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,27 +1,33 @@
import re
import sys
-import pytest
-
from pathlib import Path
from unittest.mock import patch
+import pytest
+
from zcore.cli import main
+from zcore.cli.commands import find_zcore_projects, is_zcore_project
+
@pytest.fixture
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()
@@ -32,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()
@@ -81,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"]
@@ -218,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()
@@ -234,12 +223,46 @@ 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):
+
+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
+
+
+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()
- 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
+
+ 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
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 51c0476..e3ddf2c 100644
--- a/tests/test_di.py
+++ b/tests/test_di.py
@@ -1,22 +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
@@ -82,6 +89,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)
@@ -107,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)
@@ -126,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:
@@ -149,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)
@@ -165,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"])
@@ -182,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)
@@ -207,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
@@ -300,4 +315,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
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 13ddc48..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]}"
@@ -177,16 +179,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"
diff --git a/tests/test_search.py b/tests/test_search.py
index d17a634..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)
@@ -42,6 +44,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 +134,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 +252,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",
[
@@ -175,13 +298,13 @@ 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(
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
new file mode 100644
index 0000000..a3156fa
--- /dev/null
+++ b/tests/test_soft_delete.py
@@ -0,0 +1,170 @@
+import uuid
+from collections.abc import AsyncGenerator
+from datetime import datetime
+from typing import Any
+
+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
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)