chuopycore-cli 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. chuopycore_cli-1.0.0/MANIFEST.in +1 -0
  2. chuopycore_cli-1.0.0/PKG-INFO +7 -0
  3. chuopycore_cli-1.0.0/README.md +75 -0
  4. chuopycore_cli-1.0.0/chuopycore_cli.egg-info/PKG-INFO +7 -0
  5. chuopycore_cli-1.0.0/chuopycore_cli.egg-info/SOURCES.txt +30 -0
  6. chuopycore_cli-1.0.0/chuopycore_cli.egg-info/dependency_links.txt +1 -0
  7. chuopycore_cli-1.0.0/chuopycore_cli.egg-info/entry_points.txt +2 -0
  8. chuopycore_cli-1.0.0/chuopycore_cli.egg-info/requires.txt +3 -0
  9. chuopycore_cli-1.0.0/chuopycore_cli.egg-info/top_level.txt +1 -0
  10. chuopycore_cli-1.0.0/pycore/__init__.py +0 -0
  11. chuopycore_cli-1.0.0/pycore/cli.py +106 -0
  12. chuopycore_cli-1.0.0/pycore/templates/.env.jinja +35 -0
  13. chuopycore_cli-1.0.0/pycore/templates/.gitignore.jinja +44 -0
  14. chuopycore_cli-1.0.0/pycore/templates/Dockerfile.jinja +21 -0
  15. chuopycore_cli-1.0.0/pycore/templates/alembic.ini.jinja +40 -0
  16. chuopycore_cli-1.0.0/pycore/templates/app/api/routers.py.jinja +29 -0
  17. chuopycore_cli-1.0.0/pycore/templates/app/core/exceptions.py.jinja +30 -0
  18. chuopycore_cli-1.0.0/pycore/templates/app/core/logging.py.jinja +91 -0
  19. chuopycore_cli-1.0.0/pycore/templates/app/core/security.py.jinja +23 -0
  20. chuopycore_cli-1.0.0/pycore/templates/app/core/settings.py.jinja +142 -0
  21. chuopycore_cli-1.0.0/pycore/templates/app/db/database.py.jinja +71 -0
  22. chuopycore_cli-1.0.0/pycore/templates/app/db/models/models.py.jinja +16 -0
  23. chuopycore_cli-1.0.0/pycore/templates/app/main.py.jinja +58 -0
  24. chuopycore_cli-1.0.0/pycore/templates/app/schemas/schema.py.jinja +21 -0
  25. chuopycore_cli-1.0.0/pycore/templates/app/services/service.py.jinja +25 -0
  26. chuopycore_cli-1.0.0/pycore/templates/docker-compose.yml.jinja +108 -0
  27. chuopycore_cli-1.0.0/pycore/templates/requirements.txt.jinja +31 -0
  28. chuopycore_cli-1.0.0/pycore/templates/startup.sh.jinja +23 -0
  29. chuopycore_cli-1.0.0/pycore/templates/structure.json +0 -0
  30. chuopycore_cli-1.0.0/pycore/templates/tests/test_main.py.jinja +9 -0
  31. chuopycore_cli-1.0.0/setup.cfg +4 -0
  32. chuopycore_cli-1.0.0/setup.py +18 -0
@@ -0,0 +1 @@
1
+ recursive-include pycore/templates *
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: chuopycore-cli
3
+ Version: 1.0.0
4
+ Requires-Dist: typer[all]
5
+ Requires-Dist: jinja2
6
+ Requires-Dist: rich
7
+ Dynamic: requires-dist
@@ -0,0 +1,75 @@
1
+ # chuopycore-cli 🚀
2
+
3
+ `chuopycore-cli` es una potente herramienta de interfaz de línea de comandos (CLI) escrita en Python, diseñada para acelerar el desarrollo de microservicios basados en **FastAPI**. Genera automáticamente una arquitectura profesional, escalable y lista para producción en segundos.
4
+
5
+ ## ✨ Características
6
+
7
+ - **Scaffolding Profesional**: Estructura de carpetas optimizada para mantenibilidad.
8
+ - **FastAPI Core**: Configuración avanzada con soporte para `lifespan` y middlewares.
9
+ - **Docker-Ready**: Orquestación completa con Docker Compose.
10
+ - **Arquitectura de DB Avanzada**:
11
+ - PostgreSQL con replicación **Master/Replica**.
12
+ - **PGBouncer** integrado para gestión eficiente de pool de conexiones.
13
+ - **Migraciones**: Configuración de **Alembic** lista para usar.
14
+ - **Gestión de Configuración**: Basada en `pydantic-settings` para manejo robusto de variables de entorno.
15
+ - **Scripts de Automatización**: Script `startup.sh` que gestiona esperas de DB y ejecuciones de migraciones automáticas.
16
+
17
+ ## 🛠️ Instalación
18
+
19
+ Para instalar la herramienta localmente en modo desarrollo:
20
+
21
+ ```bash
22
+ pip install -e .
23
+ ```
24
+
25
+ Esto registrará el comando `chuopycore` en tu sistema.
26
+
27
+ ## 🚀 Uso Académico y Práctico
28
+
29
+ Para generar un nuevo microservicio, simplemente ejecuta:
30
+
31
+ ```bash
32
+ chuopycore init mi-nuevo-servicio
33
+ ```
34
+
35
+ ### Opciones
36
+
37
+ - `--db`, `-d`: Incluye toda la infraestructura de base de datos (PostgreSQL, PGBouncer, SQLAlchemy, Alembic).
38
+
39
+ **Ejemplo de creación con base de datos:**
40
+
41
+ ```bash
42
+ chuopycore init facturacion-service --db
43
+ ```
44
+
45
+ ## 📂 Estructura del Proyecto Generado
46
+
47
+ El microservicio resultante seguirá este estándar:
48
+
49
+ ```text
50
+ facturacion-service/
51
+ ├── app/
52
+ │ ├── api/ # Routers y controladores
53
+ │ ├── core/ # Configuración, excepciones y logs
54
+ │ ├── db/ # Modelos y conexión a base de datos
55
+ │ ├── schemas/ # Modelos Pydantic
56
+ │ └── services/ # Lógica de negocio
57
+ ├── alembic/ # Historial de migraciones
58
+ ├── scripts/ # Scripts de utilidad
59
+ ├── tests/ # Pruebas unitarias e integración
60
+ ├── .env # Variables de entorno
61
+ ├── dockerfile # Construcción de imagen Docker
62
+ ├── docker-compose.yml # Orquestación de servicios
63
+ └── startup.sh # Script de inicialización
64
+ ```
65
+
66
+ ## 📝 Requisitos
67
+
68
+ - Python 3.10+
69
+ - Jinja2
70
+ - Typer
71
+ - Rich
72
+
73
+ ---
74
+
75
+ Desarrollado con ❤️ para la estandarización de microservicios.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: chuopycore-cli
3
+ Version: 1.0.0
4
+ Requires-Dist: typer[all]
5
+ Requires-Dist: jinja2
6
+ Requires-Dist: rich
7
+ Dynamic: requires-dist
@@ -0,0 +1,30 @@
1
+ MANIFEST.in
2
+ README.md
3
+ setup.py
4
+ chuopycore_cli.egg-info/PKG-INFO
5
+ chuopycore_cli.egg-info/SOURCES.txt
6
+ chuopycore_cli.egg-info/dependency_links.txt
7
+ chuopycore_cli.egg-info/entry_points.txt
8
+ chuopycore_cli.egg-info/requires.txt
9
+ chuopycore_cli.egg-info/top_level.txt
10
+ pycore/__init__.py
11
+ pycore/cli.py
12
+ pycore/templates/.env.jinja
13
+ pycore/templates/.gitignore.jinja
14
+ pycore/templates/Dockerfile.jinja
15
+ pycore/templates/alembic.ini.jinja
16
+ pycore/templates/docker-compose.yml.jinja
17
+ pycore/templates/requirements.txt.jinja
18
+ pycore/templates/startup.sh.jinja
19
+ pycore/templates/structure.json
20
+ pycore/templates/app/main.py.jinja
21
+ pycore/templates/app/api/routers.py.jinja
22
+ pycore/templates/app/core/exceptions.py.jinja
23
+ pycore/templates/app/core/logging.py.jinja
24
+ pycore/templates/app/core/security.py.jinja
25
+ pycore/templates/app/core/settings.py.jinja
26
+ pycore/templates/app/db/database.py.jinja
27
+ pycore/templates/app/db/models/models.py.jinja
28
+ pycore/templates/app/schemas/schema.py.jinja
29
+ pycore/templates/app/services/service.py.jinja
30
+ pycore/templates/tests/test_main.py.jinja
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ chuopycore = pycore.cli:app
@@ -0,0 +1,3 @@
1
+ typer[all]
2
+ jinja2
3
+ rich
File without changes
@@ -0,0 +1,106 @@
1
+ import typer
2
+ import os
3
+ from jinja2 import Environment, FileSystemLoader
4
+ from rich.console import Console
5
+
6
+ app = typer.Typer()
7
+ console = Console()
8
+
9
+ # Configuración de Jinja
10
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
11
+ TEMPLATE_DIR = os.path.join(BASE_DIR, 'templates')
12
+ env = Environment(loader=FileSystemLoader(TEMPLATE_DIR))
13
+
14
+ def create_file(template_path, context, output_path):
15
+ """Renderiza el template y lo guarda."""
16
+ try:
17
+ # Si el template está en subcarpeta, jinja lo busca relativo al root de templates
18
+ template = env.get_template(template_path)
19
+ content = template.render(context)
20
+
21
+ dir_name = os.path.dirname(output_path)
22
+ if dir_name:
23
+ os.makedirs(dir_name, exist_ok=True)
24
+
25
+ with open(output_path, 'w', encoding='utf-8') as f:
26
+ f.write(content)
27
+ except Exception as e:
28
+ console.print(f"[red]Error procesando {template_path}: {e}[/red]")
29
+
30
+ @app.command()
31
+ def init(
32
+ name: str = typer.Argument(..., help="Nombre del proyecto"),
33
+ db: bool = typer.Option(False, "--db", "-d", help="Incluir Base de Datos (SQLAlchemy/Alembic)")
34
+ ):
35
+ """Genera la arquitectura base."""
36
+ base_path = os.path.join(os.getcwd(), name)
37
+
38
+ if os.path.exists(base_path):
39
+ console.print(f"[bold red]❌ La carpeta {name} ya existe.[/bold red]")
40
+ raise typer.Exit()
41
+
42
+ console.print(f"🚀 Creando arquitectura en: [bold cyan]{name}[/bold cyan]")
43
+
44
+ # 1. Crear directorios vacíos necesarios (Estructura Base)
45
+ dirs = [
46
+ "alembic",
47
+ "app/api",
48
+ "app/core",
49
+ "app/db/models",
50
+ "app/schemas",
51
+ "app/services",
52
+ "scripts",
53
+ "tests",
54
+ ]
55
+
56
+ for d in dirs:
57
+ os.makedirs(os.path.join(base_path, d), exist_ok=True)
58
+
59
+ # 2. Contexto para las plantillas
60
+ context = {
61
+ "project_name": name,
62
+ "with_db": db
63
+ }
64
+
65
+ # 3. Mapeo de Templates -> Archivo Destino
66
+ files_map = {
67
+ ".gitignore.jinja": ".gitignore",
68
+ ".env.jinja": ".env",
69
+ "requirements.txt.jinja": "requirements.txt",
70
+ "Dockerfile.jinja": "dockerfile",
71
+ "docker-compose.yml.jinja": "docker-compose.yml",
72
+ "startup.sh.jinja": "startup.sh",
73
+ "alembic.ini.jinja": "alembic.ini",
74
+
75
+ # App Code
76
+ "app/main.py.jinja": "app/main.py",
77
+ "app/api/routers.py.jinja": "app/api/routers.py",
78
+ "app/core/settings.py.jinja": "app/core/settings.py",
79
+ "app/core/exceptions.py.jinja": "app/core/exceptions.py",
80
+ "app/core/logging.py.jinja": "app/core/logging.py",
81
+ "app/core/security.py.jinja": "app/core/security.py",
82
+ "app/db/models/models.py.jinja": "app/db/models/models.py",
83
+ "app/schemas/schema.py.jinja": "app/schemas/schema.py",
84
+ "app/services/service.py.jinja": "app/services/service.py",
85
+ "tests/test_main.py.jinja": "tests/test_main.py",
86
+ }
87
+
88
+ if db:
89
+ files_map["app/db/database.py.jinja"] = "app/db/database.py"
90
+
91
+ # 4. Generación
92
+ for template, dest in files_map.items():
93
+ create_file(template, context, os.path.join(base_path, dest))
94
+
95
+ # Crear __init__.py necesarios
96
+ init_dirs = ["app", "app/api", "app/core", "app/db", "app/db/models", "app/schemas", "app/services"]
97
+ for d in init_dirs:
98
+ with open(os.path.join(base_path, d, "__init__.py"), 'w') as f:
99
+ pass
100
+
101
+ console.print(f"\n✅ [green]Microservicio {name} creado con éxito.[/green]")
102
+ if db:
103
+ console.print(" 📦 Incluye configuración de SQLAlchemy y Alembic.")
104
+
105
+ if __name__ == "__main__":
106
+ app()
@@ -0,0 +1,35 @@
1
+ ENV=DEV
2
+
3
+ PROJECT_FLAG=Project: {{ project_name }}
4
+ ADMIN_KEY=GENERIC_ADMIN_KEY_CHANGE_ME
5
+ {{ project_name | upper | replace('-', '_') }}_API_KEY=GENERIC_API_KEY_CHANGE_ME
6
+
7
+ API_HOST=0.0.0.0
8
+ API_PORT=8000
9
+ API_DEBUG=True
10
+ API_WORKERS=1
11
+ ALGORITHM=HS256
12
+ ACCESS_TOKEN_EXPIRE_MINUTES=30
13
+ SECRET_KEY=CHANGE_THIS_TO_A_SECURE_SECRET_KEY
14
+
15
+ # Security Settings
16
+ ENABLE_RATE_LIMITING=True
17
+ MAX_LOGIN_ATTEMPTS=5
18
+ RATE_LIMIT_WINDOW=60
19
+
20
+ # Logging Settings
21
+ LOG_LEVEL=INFO
22
+ ENABLE_SECURITY_LOGGING=True
23
+
24
+ # Database Settings
25
+ POSTGRES_USER=postgres
26
+ POSTGRES_PASSWORD=postgres
27
+ POSTGRES_DB={{ project_name | replace('-', '_') }}_db
28
+ POSTGRES_PORT=5432
29
+ POSTGRES_HOST=localhost
30
+
31
+ # CQRS (Opcional, apuntan a master por defecto)
32
+ POSTGRES_WRITER_HOST=localhost
33
+ POSTGRES_WRITER_PORT=5432
34
+ POSTGRES_READER_HOST=localhost
35
+ POSTGRES_READER_PORT=5432
@@ -0,0 +1,44 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ env/
8
+ build/
9
+ develop-eggs/
10
+ dist/
11
+ downloads/
12
+ eggs/
13
+ .eggs/
14
+ lib/
15
+ lib64/
16
+ parts/
17
+ sdist/
18
+ var/
19
+ wheels/
20
+ *.egg-info/
21
+ .installed.cfg
22
+ *.egg
23
+
24
+ # Microservice specific
25
+ .env
26
+ .venv
27
+ venv/
28
+ ENV/
29
+ security.log
30
+ *.db
31
+ *.sqlite3
32
+
33
+ # Docker
34
+ .docker
35
+
36
+ # IDEs
37
+ .vscode/
38
+ .idea/
39
+ *.swp
40
+ *.swo
41
+
42
+ # OS
43
+ .DS_Store
44
+ Thumbs.db
@@ -0,0 +1,21 @@
1
+ FROM python:3.11-slim
2
+ WORKDIR /code
3
+
4
+ RUN apt-get update && apt-get install -y \
5
+ curl \
6
+ postgresql-client \
7
+ && apt-get clean
8
+
9
+ COPY ./requirements.txt /code/requirements.txt
10
+ RUN pip install --no-cache-dir --upgrade pip && \
11
+ pip install --no-cache-dir -r /code/requirements.txt
12
+
13
+ COPY ./app /code/app
14
+ COPY ./alembic /code/alembic
15
+ COPY ./alembic.ini /code/alembic.ini
16
+ COPY ./startup.sh /code/startup.sh
17
+
18
+ RUN sed -i 's/\r$//' /code/startup.sh && chmod +x /code/startup.sh
19
+
20
+ EXPOSE 8000
21
+ ENTRYPOINT ["/code/startup.sh"]
@@ -0,0 +1,40 @@
1
+ [alembic]
2
+ script_location = alembic
3
+ prepend_sys_path = .
4
+ version_path_separator = os
5
+
6
+ sqlalchemy.url = postgresql://%(POSTGRES_USER)s:%(POSTGRES_PASSWORD)s@%(POSTGRES_HOST)s:%(POSTGRES_PORT)s/%(POSTGRES_DB)s
7
+
8
+ [loggers]
9
+ keys = root,sqlalchemy,alembic
10
+
11
+ [handlers]
12
+ keys = console
13
+
14
+ [formatters]
15
+ keys = generic
16
+
17
+ [logger_root]
18
+ level = WARN
19
+ handlers = console
20
+ qualname =
21
+
22
+ [logger_sqlalchemy]
23
+ level = WARN
24
+ handlers =
25
+ qualname = sqlalchemy.engine
26
+
27
+ [logger_alembic]
28
+ level = INFO
29
+ handlers =
30
+ qualname = alembic
31
+
32
+ [handler_console]
33
+ class = StreamHandler
34
+ args = (sys.stderr,)
35
+ level = NOTSET
36
+ formatter = generic
37
+
38
+ [formatter_generic]
39
+ format = %(levelname)-5.5s [%(name)s] %(message)s
40
+ datefmt = %H:%M:%S
@@ -0,0 +1,29 @@
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+ from typing import List, Any
4
+
5
+ from app.db.database import get_db, get_read_db
6
+ from app.core.security import get_api_key
7
+
8
+ router = APIRouter(
9
+ prefix="/items",
10
+ tags=["Items"],
11
+ dependencies=[Depends(get_api_key)]
12
+ )
13
+
14
+ @router.get("", response_model=List[Any])
15
+ async def list_items(
16
+ skip: int = 0,
17
+ limit: int = 100,
18
+ db: AsyncSession = Depends(get_read_db)
19
+ ):
20
+ """Obtiene la lista de elementos (Ejemplo)."""
21
+ return []
22
+
23
+ @router.post("", status_code=status.HTTP_201_CREATED)
24
+ async def create_item(
25
+ item_data: Any,
26
+ db: AsyncSession = Depends(get_db)
27
+ ):
28
+ """Crea un nuevo elemento (Ejemplo)."""
29
+ return {"message": "Item creado"}
@@ -0,0 +1,30 @@
1
+ class ServiceError(Exception):
2
+ """Excepción base para los errores del microservicio {{ project_name }}."""
3
+ pass
4
+
5
+ class AuthenticationError(ServiceError):
6
+ """Lanzada cuando la autenticación falla."""
7
+ pass
8
+
9
+ class TokenError(AuthenticationError):
10
+ """Lanzada cuando hay problemas con los tokens."""
11
+ pass
12
+
13
+ class DatabaseError(ServiceError):
14
+ """Lanzada cuando las operaciones de base de datos fallan."""
15
+ pass
16
+
17
+ class APIError(ServiceError):
18
+ """Lanzada cuando las llamadas a la API fallan."""
19
+ def __init__(self, status_code: int, message: str):
20
+ self.status_code = status_code
21
+ self.message = message
22
+ super().__init__(f"API Error {status_code}: {message}")
23
+
24
+ class DataValidationError(ServiceError):
25
+ """Lanzada cuando la validación de datos falla."""
26
+ pass
27
+
28
+ class ResourceNotFoundError(ServiceError):
29
+ """Lanzada cuando un recurso solicitado no se encuentra."""
30
+ pass
@@ -0,0 +1,91 @@
1
+ import html
2
+ import logging
3
+ import requests
4
+ import traceback
5
+ from datetime import datetime
6
+
7
+ from .settings import settings
8
+
9
+ class TelegramHandler(logging.Handler):
10
+ def __init__(self) -> None:
11
+ self.token = settings.TELEGRAM_TOKEN
12
+ self.chat_ids = settings.ADMINISTRATOR_IDS
13
+ self.project_flag = settings.PROJECT_FLAG
14
+ self.api_url = f"https://api.telegram.org/bot{self.token}/sendMessage"
15
+ super().__init__()
16
+
17
+ def send_message(self, message: str):
18
+ try:
19
+ project_flag_text = f"[{self.project_flag}]\n" if self.project_flag else ""
20
+ full_message = project_flag_text + message
21
+ for chat_id in self.chat_ids:
22
+ payload = {
23
+ "chat_id": chat_id,
24
+ "text": html.escape(full_message),
25
+ "parse_mode": "HTML",
26
+ }
27
+ response = requests.post(self.api_url, data=payload, timeout=5)
28
+ if not response.ok:
29
+ print(f"ERROR: No se pudo enviar log a Telegram chat_id {chat_id}. Response: {response.text}")
30
+ except Exception as e:
31
+ print(f"CRITICAL: Excepción al enviar log a Telegram: {str(e)}")
32
+
33
+ def emit(self, record: logging.LogRecord):
34
+ try:
35
+ text = self.format(record)
36
+ if record.exc_info:
37
+ exc_type, exc_value, exc_traceback = record.exc_info
38
+ stack_trace = ''.join(traceback.format_exception(exc_type, exc_value, exc_traceback))
39
+ text += f"\n\n<b>Stack Trace:</b>\n<pre>{html.escape(stack_trace)}</pre>"
40
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
41
+ text = f"<b>[{record.levelname}]</b> {timestamp}\n{text}"
42
+ self.send_message(text)
43
+ except Exception:
44
+ self.handleError(record)
45
+
46
+ def init_logger() -> None:
47
+ app_logger = logging.getLogger()
48
+ app_logger.setLevel(logging.DEBUG)
49
+
50
+ if app_logger.hasHandlers():
51
+ app_logger.handlers.clear()
52
+
53
+ standard_formatter = logging.Formatter(
54
+ "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
55
+ )
56
+
57
+ basic_handler = logging.StreamHandler()
58
+ basic_handler.setLevel(logging.INFO)
59
+ basic_handler.setFormatter(standard_formatter)
60
+ app_logger.addHandler(basic_handler)
61
+
62
+ if settings.TELEGRAM_TOKEN and settings.ADMINISTRATOR_IDS:
63
+ telegram_handler = TelegramHandler()
64
+ telegram_handler.setLevel(logging.ERROR)
65
+ telegram_handler.setFormatter(standard_formatter)
66
+ app_logger.addHandler(telegram_handler)
67
+ app_logger.info("Handler de Telegram configurado correctamente.")
68
+
69
+ app_logger.propagate = False
70
+ app_logger.info("Logger inicializado correctamente.")
71
+
72
+ setup_security_logger()
73
+ return app_logger
74
+
75
+ def setup_security_logger():
76
+ security_logger = logging.getLogger("security")
77
+ security_logger.setLevel(logging.INFO)
78
+ security_handler = logging.FileHandler("security.log")
79
+ security_handler.setLevel(logging.INFO)
80
+ security_formatter = logging.Formatter(
81
+ '%(asctime)s - SECURITY - %(levelname)s - %(message)s'
82
+ )
83
+ security_handler.setFormatter(security_formatter)
84
+ security_logger.addHandler(security_handler)
85
+ security_logger.propagate = False
86
+ return security_logger
87
+
88
+ # Funciones de utilidad para logging de seguridad
89
+ def log_security_event(event_type: str, details: str, ip: str = "unknown"):
90
+ security_logger = logging.getLogger("security")
91
+ security_logger.info(f"{event_type} - {details} - IP: {ip}")
@@ -0,0 +1,23 @@
1
+ from fastapi import Security, HTTPException, status
2
+ from fastapi.security.api_key import APIKeyHeader
3
+ from app.core.settings import settings
4
+
5
+ # Definir el nombre del header donde se espera el API Key
6
+ API_KEY_NAME = "X-API-Key"
7
+ api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)
8
+
9
+ async def get_api_key(api_key: str = Security(api_key_header)):
10
+ """
11
+ Dependencia para validar el API Key en los headers de la solicitud.
12
+ """
13
+ # Se espera una variable de entorno con el nombre del proyecto en mayúsculas + _API_KEY
14
+ expected_api_key = getattr(settings, "{{ project_name | upper | replace('-', '_') }}_API_KEY", None)
15
+
16
+ if api_key and api_key == expected_api_key:
17
+ return api_key
18
+
19
+ raise HTTPException(
20
+ status_code=status.HTTP_401_UNAUTHORIZED,
21
+ detail="API Key inválida o ausente",
22
+ headers={"WWW-Authenticate": "ApiKey"},
23
+ )
@@ -0,0 +1,142 @@
1
+ import enum
2
+ import os
3
+ from typing import Union, Any, List
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+ from pydantic import field_validator
6
+ import json
7
+ from datetime import datetime
8
+
9
+ class Enviroments(enum.IntEnum):
10
+ """Enumeración de los entornos de ejecución disponibles."""
11
+ DEV = 1
12
+ TESTING = 2
13
+ PROD = 3
14
+
15
+ @classmethod
16
+ def from_str(cls, value: Union[str, int]) -> 'Enviroments':
17
+ if isinstance(value, int) or (isinstance(value, str) and value.isdigit()):
18
+ return cls(int(value))
19
+ value_map = {
20
+ 'DEV': cls.DEV,
21
+ 'TESTING': cls.TESTING,
22
+ 'PROD': cls.PROD
23
+ }
24
+ return value_map[value.upper()]
25
+
26
+ class Settings(BaseSettings):
27
+ """Configuración base de la aplicación."""
28
+ # Configuración del entorno
29
+ ENV: Enviroments = Enviroments.DEV
30
+
31
+ # Configuración de Telegram para logging (Opcional)
32
+ TELEGRAM_TOKEN: str = ""
33
+ ADMINISTRATOR_IDS: List[str] = []
34
+ PROJECT_FLAG: str = ""
35
+ ADMIN_KEY: str = ""
36
+
37
+ # Seguridad: API Key
38
+ {{ project_name | upper | replace('-', '_') }}_API_KEY: str = ""
39
+
40
+ # Configuración del servidor API
41
+ API_HOST: str = ""
42
+ API_PORT: int = 8000
43
+ API_DEBUG: bool = True
44
+ API_WORKERS: int = 1
45
+
46
+ # Configuraciones de seguridad de la API
47
+ ALGORITHM: str = "HS256"
48
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
49
+ SECRET_KEY: str = ""
50
+
51
+ # Configuraciones de seguridad adicionales
52
+ ENABLE_RATE_LIMITING: bool = True
53
+ MAX_LOGIN_ATTEMPTS: int = 5
54
+ RATE_LIMIT_WINDOW: int = 60
55
+
56
+ # Configuraciones de logging
57
+ LOG_LEVEL: str = "INFO"
58
+ ENABLE_SECURITY_LOGGING: bool = True
59
+
60
+ # Configuración de la base de datos PostgreSQL
61
+ POSTGRES_USER: str = "postgres"
62
+ POSTGRES_PASSWORD: str = "postgres"
63
+ POSTGRES_DB: str = "{{ project_name | replace('-', '_') }}_db"
64
+ POSTGRES_HOST: str = "db"
65
+ POSTGRES_PORT: int = 5432
66
+
67
+ # CQRS: Hosts específicos para lectura y escritura
68
+ POSTGRES_WRITER_HOST: str = ""
69
+ POSTGRES_WRITER_PORT: int = 0
70
+ POSTGRES_READER_HOST: str = ""
71
+ POSTGRES_READER_PORT: int = 0
72
+
73
+ DATABASE_URL: str = ""
74
+
75
+ model_config = SettingsConfigDict(
76
+ env_file=".env",
77
+ env_file_encoding="utf-8",
78
+ extra="allow",
79
+ env_nested_delimiter="__",
80
+ case_sensitive=False
81
+ )
82
+
83
+ def __init__(self, **values):
84
+ super().__init__(**values)
85
+
86
+ if not self.POSTGRES_WRITER_HOST:
87
+ self.POSTGRES_WRITER_HOST = self.POSTGRES_HOST
88
+ if not self.POSTGRES_WRITER_PORT:
89
+ self.POSTGRES_WRITER_PORT = self.POSTGRES_PORT
90
+
91
+ if not self.POSTGRES_READER_HOST:
92
+ self.POSTGRES_READER_HOST = self.POSTGRES_HOST
93
+ if not self.POSTGRES_READER_PORT:
94
+ self.POSTGRES_READER_PORT = self.POSTGRES_PORT
95
+
96
+ self.DATABASE_URL = self.get_writer_database_url()
97
+
98
+ @field_validator('ENV', mode='before')
99
+ @classmethod
100
+ def validate_env(cls, value):
101
+ if value is None:
102
+ return Enviroments.DEV
103
+ try:
104
+ return Enviroments.from_str(value)
105
+ except (KeyError, ValueError):
106
+ return Enviroments.DEV
107
+
108
+ @field_validator('ADMINISTRATOR_IDS', mode='before')
109
+ @classmethod
110
+ def validate_admin_ids(cls, value):
111
+ if isinstance(value, str):
112
+ try:
113
+ return json.loads(value)
114
+ except json.JSONDecodeError:
115
+ return [value]
116
+ return value
117
+
118
+ def get_database_url(self) -> str:
119
+ return self.DATABASE_URL
120
+
121
+ def get_writer_database_url(self) -> str:
122
+ return (
123
+ "postgresql+asyncpg://"
124
+ f"{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@"
125
+ f"{self.POSTGRES_WRITER_HOST}:{self.POSTGRES_WRITER_PORT}/{self.POSTGRES_DB}"
126
+ )
127
+
128
+ def get_reader_database_url(self) -> str:
129
+ return (
130
+ "postgresql+asyncpg://"
131
+ f"{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@"
132
+ f"{self.POSTGRES_READER_HOST}:{self.POSTGRES_READER_PORT}/{self.POSTGRES_DB}"
133
+ )
134
+
135
+ def get_sync_database_url(self) -> str:
136
+ return (
137
+ "postgresql://"
138
+ f"{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@"
139
+ f"{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
140
+ )
141
+
142
+ settings = Settings()
@@ -0,0 +1,71 @@
1
+ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
2
+ from sqlalchemy.orm import declarative_base
3
+ from app.core.settings import settings
4
+
5
+ Base = declarative_base()
6
+
7
+ # -----------------------------------------------------------------------------
8
+ # Configuración de Motores (Master/Replica)
9
+ # -----------------------------------------------------------------------------
10
+
11
+ # Motor de Escritura (Master)
12
+ writer_engine = create_async_engine(
13
+ settings.get_writer_database_url(),
14
+ echo=settings.DB_ECHO,
15
+ pool_size=20,
16
+ max_overflow=10,
17
+ pool_timeout=30,
18
+ pool_recycle=1800,
19
+ pool_pre_ping=True
20
+ )
21
+
22
+ # Motor de Lectura (Replica)
23
+ # Si no hay replica configurada, settings devolverá la misma URL del master
24
+ reader_engine = create_async_engine(
25
+ settings.get_reader_database_url(),
26
+ echo=settings.DB_ECHO,
27
+ pool_size=20,
28
+ max_overflow=10,
29
+ pool_timeout=30,
30
+ pool_recycle=1800,
31
+ pool_pre_ping=True
32
+ )
33
+
34
+ # -----------------------------------------------------------------------------
35
+ # Sesiones Asíncronas
36
+ # -----------------------------------------------------------------------------
37
+
38
+ AsyncWriterSession = async_sessionmaker(writer_engine, expire_on_commit=False)
39
+ AsyncReaderSession = async_sessionmaker(reader_engine, expire_on_commit=False)
40
+
41
+ # Alias para compatibilidad
42
+ AsyncSessionLocal = AsyncWriterSession
43
+ engine = writer_engine
44
+
45
+ # -----------------------------------------------------------------------------
46
+ # Dependencias (Dependency Injection)
47
+ # -----------------------------------------------------------------------------
48
+
49
+ async def get_db():
50
+ """Dependencia por defecto (Escritura/Master)."""
51
+ async with AsyncWriterSession() as session:
52
+ try:
53
+ yield session
54
+ finally:
55
+ await session.close()
56
+
57
+ async def get_write_db():
58
+ """Dependencia explícita para escritura."""
59
+ async with AsyncWriterSession() as session:
60
+ try:
61
+ yield session
62
+ finally:
63
+ await session.close()
64
+
65
+ async def get_read_db():
66
+ """Dependencia explícita para lectura."""
67
+ async with AsyncReaderSession() as session:
68
+ try:
69
+ yield session
70
+ finally:
71
+ await session.close()
@@ -0,0 +1,16 @@
1
+ from sqlalchemy import Column, Integer, String, DateTime, Boolean
2
+ from sqlalchemy.sql import func
3
+ from app.db.database import Base
4
+
5
+ class BaseModel(Base):
6
+ __abstract__ = True
7
+ id = Column(Integer, primary_key=True, index=True)
8
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
9
+ updated_at = Column(DateTime(timezone=True), onupdate=func.now())
10
+ is_active = Column(Boolean, default=True)
11
+
12
+ class Item(BaseModel):
13
+ __tablename__ = "items"
14
+
15
+ name = Column(String, index=True)
16
+ description = Column(String, nullable=True)
@@ -0,0 +1,58 @@
1
+ from contextlib import asynccontextmanager
2
+ from fastapi import Depends, FastAPI
3
+ from fastapi.responses import ORJSONResponse
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+ import logging
6
+
7
+ from app.core.settings import settings
8
+ from app.core.logging import init_logger
9
+ from app.api.routers import router as api_router
10
+ {% if with_db %}
11
+ from app.db.database import engine, Base
12
+ {% endif %}
13
+
14
+ # Inicializar logger profesional
15
+ logger = init_logger()
16
+
17
+ @asynccontextmanager
18
+ async def lifespan(app: FastAPI):
19
+ # Startup
20
+ logger.info(f"Iniciando microservicio {settings.PROJECT_FLAG}...")
21
+
22
+ {% if with_db %}
23
+ # Crear tablas (Solo para desarrollo/testing - en prod usar Alembic)
24
+ async with engine.begin() as conn:
25
+ await conn.run_sync(Base.metadata.create_all)
26
+ {% endif %}
27
+
28
+ yield
29
+
30
+ # Shutdown
31
+ logger.info(f"Apagando microservicio {settings.PROJECT_FLAG}...")
32
+
33
+ app = FastAPI(
34
+ title="{{ project_name }} API",
35
+ description="API generada por chuopycore-cli",
36
+ version="1.0.0",
37
+ lifespan=lifespan,
38
+ debug=settings.API_DEBUG,
39
+ default_response_class=ORJSONResponse
40
+ )
41
+
42
+ # CORS config
43
+ app.add_middleware(
44
+ CORSMiddleware,
45
+ allow_origins=["*"],
46
+ allow_credentials=True,
47
+ allow_methods=["*"],
48
+ allow_headers=["*"],
49
+ )
50
+
51
+ app.include_router(
52
+ api_router,
53
+ prefix="/api/v1"
54
+ )
55
+
56
+ @app.get("/health")
57
+ async def health_check():
58
+ return {"status": "ok", "service": settings.PROJECT_FLAG}
@@ -0,0 +1,21 @@
1
+ from pydantic import BaseModel, ConfigDict
2
+ from typing import Optional
3
+ from datetime import datetime
4
+
5
+ class ItemBase(BaseModel):
6
+ name: str
7
+ description: Optional[str] = None
8
+
9
+ class ItemCreate(ItemBase):
10
+ pass
11
+
12
+ class ItemUpdate(ItemBase):
13
+ name: Optional[str] = None
14
+
15
+ class ItemResponse(ItemBase):
16
+ id: int
17
+ created_at: datetime
18
+ updated_at: Optional[datetime] = None
19
+ is_active: bool
20
+
21
+ model_config = ConfigDict(from_attributes=True)
@@ -0,0 +1,25 @@
1
+ from sqlalchemy.ext.asyncio import AsyncSession
2
+ from sqlalchemy.future import select
3
+ from typing import List, Optional
4
+
5
+ from app.db.models.models import Item
6
+ from app.schemas.schema import ItemCreate, ItemUpdate
7
+
8
+ class ItemService:
9
+ @staticmethod
10
+ async def get_all(db: AsyncSession, skip: int = 0, limit: int = 100) -> List[Item]:
11
+ result = await db.execute(select(Item).offset(skip).limit(limit))
12
+ return result.scalars().all()
13
+
14
+ @staticmethod
15
+ async def get_by_id(db: AsyncSession, item_id: int) -> Optional[Item]:
16
+ result = await db.execute(select(Item).filter(Item.id == item_id))
17
+ return result.scalars().first()
18
+
19
+ @staticmethod
20
+ async def create(db: AsyncSession, item_data: ItemCreate) -> Item:
21
+ db_item = Item(**item_data.model_dump())
22
+ db.add(db_item)
23
+ await db.commit()
24
+ await db.refresh(db_item)
25
+ return db_item
@@ -0,0 +1,108 @@
1
+ services:
2
+ {{ project_name }}-api:
3
+ build: .
4
+ container_name: {{ project_name }}-api
5
+ command: /code/startup.sh
6
+ volumes:
7
+ - ./app:/code/app
8
+ - ./alembic:/code/alembic
9
+ - ./alembic.ini:/code/alembic.ini
10
+ ports:
11
+ - "8000:8000"
12
+ env_file:
13
+ - .env
14
+ environment:
15
+ - POSTGRES_WRITER_HOST=pgbouncer-writer
16
+ - POSTGRES_WRITER_PORT=6432
17
+ - POSTGRES_READER_HOST=pgbouncer-reader
18
+ - POSTGRES_READER_PORT=6433
19
+ - POSTGRES_HOST={{ project_name }}-db-master
20
+ - POSTGRES_PORT=5432
21
+ depends_on:
22
+ pgbouncer-writer:
23
+ condition: service_started
24
+ pgbouncer-reader:
25
+ condition: service_started
26
+ {{ project_name }}-db-master:
27
+ condition: service_healthy
28
+ restart: on-failure
29
+ networks:
30
+ - {{ project_name }}_network
31
+
32
+ pgbouncer-writer:
33
+ image: edoburu/pgbouncer:latest
34
+ container_name: {{ project_name }}-pgbouncer-writer
35
+ environment:
36
+ - DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@{{ project_name }}-db-master:5432/${POSTGRES_DB}
37
+ - POOL_MODE=transaction
38
+ - MAX_CLIENT_CONN=100
39
+ - DEFAULT_POOL_SIZE=20
40
+ - LISTEN_PORT=6432
41
+ - AUTH_TYPE=scram-sha-256
42
+ depends_on:
43
+ {{ project_name }}-db-master:
44
+ condition: service_healthy
45
+ networks:
46
+ - {{ project_name }}_network
47
+
48
+ pgbouncer-reader:
49
+ image: edoburu/pgbouncer:latest
50
+ container_name: {{ project_name }}-pgbouncer-reader
51
+ environment:
52
+ - DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@{{ project_name }}-db-replica:5432/${POSTGRES_DB}
53
+ - POOL_MODE=transaction
54
+ - MAX_CLIENT_CONN=200
55
+ - DEFAULT_POOL_SIZE=40
56
+ - LISTEN_PORT=6433
57
+ - AUTH_TYPE=scram-sha-256
58
+ depends_on:
59
+ {{ project_name }}-db-replica:
60
+ condition: service_started
61
+ networks:
62
+ - {{ project_name }}_network
63
+
64
+ {{ project_name }}-db-master:
65
+ image: bitnami/postgresql:latest
66
+ container_name: {{ project_name }}-db-master
67
+ volumes:
68
+ - {{ project_name }}_postgres_master_data:/bitnami/postgresql
69
+ environment:
70
+ - POSTGRESQL_USERNAME=${POSTGRES_USER}
71
+ - POSTGRESQL_PASSWORD=${POSTGRES_PASSWORD}
72
+ - POSTGRESQL_DATABASE=${POSTGRES_DB}
73
+ - POSTGRESQL_REPLICATION_MODE=master
74
+ - POSTGRESQL_REPLICATION_USER=repl_user
75
+ - POSTGRESQL_REPLICATION_PASSWORD=repl_password
76
+ healthcheck:
77
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
78
+ interval: 5s
79
+ timeout: 5s
80
+ retries: 5
81
+ networks:
82
+ - {{ project_name }}_network
83
+
84
+ {{ project_name }}-db-replica:
85
+ image: bitnami/postgresql:latest
86
+ container_name: {{ project_name }}-db-replica
87
+ volumes:
88
+ - {{ project_name }}_postgres_replica_data:/bitnami/postgresql
89
+ environment:
90
+ - POSTGRESQL_MASTER_HOST={{ project_name }}-db-master
91
+ - POSTGRESQL_MASTER_PORT_NUMBER=5432
92
+ - POSTGRESQL_REPLICATION_MODE=slave
93
+ - POSTGRESQL_REPLICATION_USER=repl_user
94
+ - POSTGRESQL_REPLICATION_PASSWORD=repl_password
95
+ - POSTGRESQL_PASSWORD=${POSTGRES_PASSWORD}
96
+ depends_on:
97
+ {{ project_name }}-db-master:
98
+ condition: service_healthy
99
+ networks:
100
+ - {{ project_name }}_network
101
+
102
+ networks:
103
+ {{ project_name }}_network:
104
+ driver: bridge
105
+
106
+ volumes:
107
+ {{ project_name }}_postgres_master_data:
108
+ {{ project_name }}_postgres_replica_data:
@@ -0,0 +1,31 @@
1
+ # --- FastAPI Core ---
2
+ fastapi==0.111.0
3
+ uvicorn[standard]==0.30.1
4
+ gunicorn==22.0.0
5
+ orjson==3.10.5
6
+
7
+ # --- Database ---
8
+ sqlalchemy==2.0.31
9
+ alembic==1.13.1
10
+ asyncpg==0.29.0
11
+ psycopg[binary]>=3.1.18
12
+ psycopg2-binary==2.9.9
13
+ backoff==2.2.1
14
+
15
+ # --- Settings and .env ---
16
+ pydantic-settings==2.3.4
17
+ python-dotenv==1.0.1
18
+
19
+ # --- Security ---
20
+ passlib[bcrypt]==1.7.4
21
+ bcrypt==3.2.0
22
+ python-jose[cryptography]==3.3.0
23
+ slowapi==0.1.9
24
+ requests==2.31.0
25
+
26
+ # --- Testing ---
27
+ pytest==8.2.2
28
+ httpx==0.27.0
29
+ pytest-asyncio==0.23.6
30
+ aiosqlite==0.19.0
31
+ tenacity==8.2.3
@@ -0,0 +1,23 @@
1
+ #!/bin/bash
2
+
3
+ # Esperar a que la base de datos esté lista
4
+ postgres_ready() {
5
+ pg_isready -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER"
6
+ }
7
+
8
+ until postgres_ready; do
9
+ >&2 echo "PostgreSQL no está listo - esperando..."
10
+ sleep 1
11
+ done
12
+
13
+ >&2 echo "PostgreSQL está listo."
14
+
15
+ # Ejecutar migraciones si existe el directorio alembic
16
+ if [ -d "/code/alembic" ]; then
17
+ echo "Ejecutando migraciones de base de datos..."
18
+ alembic upgrade head
19
+ fi
20
+
21
+ # Iniciar la aplicación
22
+ echo "Iniciando {{ project_name }}..."
23
+ exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
File without changes
@@ -0,0 +1,9 @@
1
+ from fastapi.testclient import TestClient
2
+ from app.main import app
3
+
4
+ client = TestClient(app)
5
+
6
+ def test_health_check():
7
+ response = client.get("/health")
8
+ assert response.status_code == 200
9
+ assert response.json()["status"] == "ok"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,18 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name="chuopycore-cli",
5
+ version="1.0.0",
6
+ packages=find_packages(),
7
+ include_package_data=True,
8
+ install_requires=[
9
+ "typer[all]",
10
+ "jinja2",
11
+ "rich"
12
+ ],
13
+ entry_points={
14
+ 'console_scripts': [
15
+ 'chuopycore=pycore.cli:app',
16
+ ],
17
+ },
18
+ )