Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ pAPI is designed to let you build composable APIs through reusable "addons" (sel
- Database abstraction with async support
- Direct exposure of FastAPI routes as tools compatible with the **Model Context Protocol (MCP)** — enabling seamless integration with LLM-based agents


Aquí tienes una versión más clara, profesional y precisa de la sección, con estilo consistente y mejor redacción técnica:

---

## 🔧 Core Features
Expand Down Expand Up @@ -191,7 +188,7 @@ All `APIException`s are automatically serialized into the same response format u

### 🛠️ Developer-Friendly CLI

pAPI ships with a powerful, extensible CLI designed to streamline development, introspection, and deployment workflows.
pAPI ships with a basic CLI designed to streamline development, introspection, and deployment workflows.

```bash
$ papi_cli start webserver # Launch the FastAPI server with all registered addons
Expand Down Expand Up @@ -403,7 +400,7 @@ pAPI is designed to enable modern backend patterns with minimal boilerplate. Som
Architect modular applications where business logic, models, and routes are grouped by tenant, domain, or business unit.

* **Internal Developer Tools & Microservices**
Rapidly prototype lightweight internal services or CLI-extended tools that benefit from a unified config, database access, and CLI environment.
Rapidly prototype lightweight internal services, MCP tools that benefit from a unified config, database access.

---

Expand Down
10 changes: 5 additions & 5 deletions papi/core/db/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from .db_creation import create_database_if_not_exists
from .query_helper import query_helper
from .redis import get_redis_client, get_redis_uri_with_db
from .sql_session import get_sql_session, sql_session
from .sql_utils import extract_bases_from_models
from .redis.redis import get_redis_client, get_redis_uri_with_db
from .sql.db_creation import create_database_if_not_exists
from .sql.query_helper import query_helper
from .sql.sql_session import get_sql_session, sql_session
from .sql.sql_utils import extract_bases_from_models

__all__ = [
"get_redis_client",
Expand Down
28 changes: 28 additions & 0 deletions papi/core/db/factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from typing import Dict, Type

from papi.core.models.db.base import BackendSettings
from papi.core.models.db.mongodb import MongoDBEngineConfig
from papi.core.models.db.redis import RedisEngineConfig
from papi.core.models.db.sql import SQLAlchemyEngineConfig

BACKEND_MODEL_REGISTRY: Dict[str, Type[BackendSettings]] = {
"sqlalchemy": SQLAlchemyEngineConfig,
"redis": RedisEngineConfig,
"mongodb": MongoDBEngineConfig,
}


def load_backend_config(name: str, config: dict) -> BackendSettings:
"""
Factory function to load the appropriate backend config model
based on the backend name.

Args:
name (str): The name of the backend (e.g., 'sqlalchemy').
config (dict): The raw dictionary configuration.

Returns:
BackendSettings: A properly typed and validated backend config model.
"""
model_cls = BACKEND_MODEL_REGISTRY.get(name, BackendSettings)
return model_cls(**config)
34 changes: 27 additions & 7 deletions papi/core/db/redis.py → papi/core/db/redis/redis.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Optional
from urllib.parse import urlparse, urlunparse

from loguru import logger
from redis.asyncio import Redis, from_url

from papi.core.settings import get_config
Expand All @@ -24,7 +25,7 @@ def get_redis_uri_with_db(base_uri: str, db_index: int) -> str:
return urlunparse(parsed._replace(path=new_path))


async def get_redis_client() -> Redis | None:
async def get_redis_client() -> Optional[Redis]:
"""
Lazily initialize and return a singleton Redis client.

Expand All @@ -34,11 +35,30 @@ async def get_redis_client() -> Redis | None:
Redis: An asyncio-compatible Redis client instance.
"""
global _redis
if _redis is not None:
logger.debug("Reusing existing Redis client.")
return _redis

config = get_config()
if _redis is None:
if config.database and config.database.redis_uri:
_redis = from_url(
config.database.redis_uri,
decode_responses=True,
)

if config.database:
redis_backend = config.database.get_backend("redis")
return None

if not redis_backend.url:
logger.warning(
"Redis URI is not configured. Redis client will not be initialized."
)
return None

logger.info("Initializing Redis client...")
logger.debug("Redis backend config: {}", redis_backend.get_defined_fields())

try:
_redis = from_url(**redis_backend.get_defined_fields())
logger.success("Redis client initialized successfully.")
except Exception as e:
logger.exception("Failed to initialize Redis client: {}", e)
_redis = None

return _redis
File renamed without changes.
File renamed without changes.
15 changes: 2 additions & 13 deletions papi/core/db/sql_session.py → papi/core/db/sql/sql_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from loguru import logger
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import AsyncAdaptedQueuePool

from papi.core.settings import get_config

Expand Down Expand Up @@ -38,25 +37,15 @@ async def get_sql_session() -> AsyncGenerator[AsyncSession, None]:
AsyncSession: Database session instance
"""
config = get_config()
sql_uri = config.database.sql_uri
sql_alchemy_cfg = config.database.get_backend("sqlalchemy").get_defined_fields()

# Validate configuration
if not sql_uri:
log.critical("Database SQL_URI not configured")
raise RuntimeError("Database configuration missing: SQL_URI not set")

# Create engine with production-ready settings
engine = create_async_engine(
sql_uri,
future=True,
poolclass=AsyncAdaptedQueuePool,
# pool_size=config.database.pool_size,
# max_overflow=config.database.max_overflow,
# pool_timeout=config.database.pool_timeout,
# pool_recycle=config.database.pool_recycle,
# echo=config.database.echo_sql,
# connect_args={"timeout": config.database.connect_timeout},
)
engine = create_async_engine(**sql_alchemy_cfg)

# Configure session factory
session_factory = async_sessionmaker(
Expand Down
File renamed without changes.
14 changes: 8 additions & 6 deletions papi/core/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ async def shutdown_addons(modules: dict[str, ModuleType]) -> None:
logger.exception(f"Error during shutdown of addon '{addon_id}': {e}")


async def init_mongodb(config, modules: dict[str, ModuleType]) -> dict[str, type]:
async def init_mongodb_beanie(
config, modules: dict[str, ModuleType]
) -> dict[str, type]:
"""
Initializes MongoDB (via Beanie) if documents are found and configuration is valid.

Expand Down Expand Up @@ -186,12 +188,12 @@ async def init_sqlalchemy(

bases = extract_bases_from_models(sqlalchemy_models)

sql_alchemy_cfg = config.database.get_backend("sqlalchemy").get_defined_fields()

try:
await create_database_if_not_exists(config.database.sql_uri)
await create_database_if_not_exists(sql_alchemy_cfg["url"])

engine: AsyncEngine = create_async_engine(
config.database.sql_uri, echo=False, future=True
)
engine: AsyncEngine = create_async_engine(**sql_alchemy_cfg)

logger.info(
f"SQLAlchemy engine initialized with {len(sqlalchemy_models)} models."
Expand Down Expand Up @@ -265,7 +267,7 @@ async def init_base_system(init_db_system: bool = True) -> dict | None:
if init_db_system and config.database:
# Init MongoDB Documents, and Beanie models on system Startup.
if config.database.mongodb_uri:
beanie_document_models = await init_mongodb(config, modules)
beanie_document_models = await init_mongodb_beanie(config, modules)
else:
beanie_document_models = []

Expand Down
33 changes: 2 additions & 31 deletions papi/core/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

from pydantic import BaseModel, Field, field_validator

from .db.main import DatabaseConfig


class StorageConfig(BaseModel):
"""
Expand All @@ -32,37 +34,6 @@ class Config:
extra = "allow"


class DatabaseConfig(BaseModel):
"""
Connection URIs for supported databases.

Attributes:
mongodb_uri (Optional[str]): MongoDB connection string.
redis_uri (Optional[str]): Redis connection string.
sql_uri (Optional[str]): SQL database connection string (PostgreSQL, MySQL, or SQLite).

Extra fields are allowed and will be preserved.

Example:
```python
DatabaseConfig(
mongodb_uri="mongodb://localhost:27017",
redis_uri="redis://localhost:6379",
sql_uri="postgresql+asyncpg://user:pass@localhost/dbname",
)
```
"""

mongodb_uri: Optional[str] = Field(default="", description="MongoDB connection URI")
redis_uri: Optional[str] = Field(default="", description="Redis connection URI")
sql_uri: Optional[str] = Field(
default="", description="SQL (PostgreSQL/MySQL/SQlite) connection URI"
)

class Config:
extra = "allow"


class AddonsConfig(BaseModel):
"""
Configuration for the plugin/addon system.
Expand Down
3 changes: 3 additions & 0 deletions papi/core/models/db/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .base import BackendSettings
from .main import DatabaseConfig
from .sql import SQLAlchemyEngineConfig
44 changes: 44 additions & 0 deletions papi/core/models/db/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from typing import Any, Dict

from pydantic import BaseModel, Field


class BackendSettings(BaseModel):
"""
Advanced settings for a database backend.

All backends must provide a `uri`, which is the main connection string.
Additional backend-specific configuration (e.g., connect_args, auth, pool size)
can be passed freely, as this model allows extra fields.

This structure enforces a consistent configuration interface across different
backends, while still supporting flexible and extensible parameters.

Attributes:
uri (str): The database connection URI.

Example:
```yaml
database:
backends:
sqlalchemy:
uri: "postgresql+asyncpg://user:pass@localhost/db"
execution_options:
isolation_level: "READ COMMITTED"
redis:
uri: "redis://localhost:6379/0"
socket_timeout: 5
```
"""

url: str = Field(..., description="DB connection URI (required)")

class Config:
extra = "allow"

def get_defined_fields(self) -> Dict[str, Any]:
"""
Returns:
dict: A dictionary of fields that were explicitly defined (not defaults).
"""
return self.model_dump(exclude_unset=True)
Loading
Loading