mtpk-postgres 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,5 @@
1
+ __version__ = "0.1.0"
2
+ __author__ = "jjandres"
3
+
4
+ from .interface import *
5
+ from .crud import AsyncCrudBase
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env python3
2
+ #-*- coding: UTF-8 -*-
3
+ # ----------------------------------------
4
+ # jjandres 2025
5
+ # Adaptación a Postgres (psycopg, modo async) de mtpk_mariadb.async_adapter
6
+ # ----------------------------------------
7
+
8
+
9
+ # Esta librería es un wrapper (envolvente) del conector psycopg para manejo de Postgres con funciones asíncronas.
10
+
11
+ import psycopg
12
+ from psycopg.rows import dict_row
13
+ from typing import Optional, Union, List, Dict
14
+ from .core_sync import Tabla, SQLLiteral
15
+ from logging import Logger
16
+ from contextlib import asynccontextmanager
17
+ from contextvars import ContextVar
18
+
19
+
20
+ class _PseudoPool:
21
+ """Sustituto de un pool real que NO reutiliza conexiones.
22
+ - Mantiene interfaz mínima usada: acquire(), close(), wait_closed(), _closed.
23
+ - Cada acquire() abre conexión con psycopg.AsyncConnection.connect() y la cierra al salir del contexto.
24
+ """
25
+ def __init__(self, host: str, user: str, password: str, db: str, port: int, autocommit: bool = False):
26
+ self._cfg = dict(host=host, user=user, password=password, dbname=db, port=port)
27
+ self._autocommit = autocommit
28
+ self._closed = False
29
+
30
+ @asynccontextmanager
31
+ async def acquire(self):
32
+ cx = await psycopg.AsyncConnection.connect(**self._cfg, autocommit=self._autocommit, row_factory=dict_row)
33
+ try:
34
+ yield cx
35
+ finally:
36
+ try:
37
+ if not cx.autocommit:
38
+ try:
39
+ await cx.rollback()
40
+ except Exception:
41
+ pass
42
+ finally:
43
+ await cx.close()
44
+
45
+ def close(self):
46
+ self._closed = True
47
+
48
+ async def wait_closed(self):
49
+ return
50
+
51
+
52
+ class AsyncDatabase:
53
+ """
54
+ Clase AsyncDatabase para entornos asíncronos como FastAPI con Uvicorn.
55
+ Usa psycopg (modo async) como backend de conexión.
56
+ """
57
+
58
+ def __init__(self, host: str, user: str, password: str, db: str, port: int = 5432, logger: Optional[Logger] = None, **kwargs):
59
+ self.host = host
60
+ self.user = user
61
+ self.password = password
62
+ self.db = db
63
+ self.port = port
64
+ self.logger = logger
65
+ self.name = kwargs.get("name") # Compatibilidad con configuraciones que incluyen 'name'
66
+
67
+ # Mantenemos el atributo pool por compatibilidad
68
+ self.pool = None
69
+
70
+ # Para compatibilidad con la gestión de tablas / sync
71
+ self.tablas: Dict[str, Tabla] = {}
72
+
73
+ # Último insert id (cuando la sentencia usa RETURNING)
74
+ self.ultimo_insert_id: Optional[int] = None
75
+
76
+ # Bandera de transacción por coroutine
77
+ self._tx_flag: ContextVar[bool] = ContextVar("mtpk_tx_flag", default=False)
78
+
79
+ def en_transaccion(self) -> bool:
80
+ """Indica si hay una transacción abierta en esta coroutine."""
81
+ return self._tx_flag.get()
82
+
83
+ @asynccontextmanager
84
+ async def transaccion(self, autocommit: bool = False):
85
+ """
86
+ Contexto de transacción con compatibilidad hacia atrás.
87
+
88
+ Params:
89
+ autocommit (bool): Se mantiene por compatibilidad con transaccion_simple().
90
+ - False (por defecto): se hace COMMIT/ROLLBACK al salir del bloque.
91
+ - True: NO se hace COMMIT/ROLLBACK; se cede la conexión tal cual.
92
+ Útil para operaciones sueltas que quieren controlar su propio commit,
93
+ o para simples lecturas.
94
+
95
+ Nota:
96
+ Aunque internamente no hay pool real, `self.pool.acquire()` usa _PseudoPool
97
+ y abre/cierra una conexión por bloque.
98
+ """
99
+ if self.pool is None:
100
+ await self.conectar()
101
+
102
+ token = self._tx_flag.set(True)
103
+ try:
104
+ async with self.pool.acquire() as cx:
105
+ try:
106
+ await cx.set_autocommit(autocommit)
107
+ except Exception:
108
+ pass
109
+
110
+ try:
111
+ yield cx
112
+ if not autocommit:
113
+ await cx.commit()
114
+ except Exception:
115
+ if not autocommit:
116
+ try:
117
+ await cx.rollback()
118
+ except Exception:
119
+ pass
120
+ raise
121
+ finally:
122
+ self._tx_flag.reset(token)
123
+
124
+ transaccion_simple = transaccion # Alias de transaccion
125
+
126
+ async def conectar(self):
127
+ """
128
+ Crea un 'pool' compatible si no está ya activo. En realidad es un _PseudoPool:
129
+ cada acquire() abre/cierra su propia conexión (sin reutilización).
130
+ """
131
+ if self.pool is None or getattr(self.pool, "_closed", False):
132
+ self.pool = _PseudoPool(
133
+ host=self.host, user=self.user, password=self.password,
134
+ db=self.db, port=self.port, autocommit=False
135
+ )
136
+ return self.pool
137
+
138
+ async def cerrar(self) -> None:
139
+ """
140
+ No-op seguro: mantiene compatibilidad con código que esperaba cerrar un pool.
141
+ """
142
+ try:
143
+ if hasattr(self, "pool") and self.pool:
144
+ try:
145
+ self.pool.close()
146
+ await self.pool.wait_closed()
147
+ except Exception:
148
+ pass
149
+ self.pool = None
150
+ except Exception as e:
151
+ if self.logger:
152
+ self.logger.error("Error cerrando pool: %s", e, exc_info=True)
153
+
154
+ def add_tabla(self, tabla: Tabla):
155
+ """
156
+ Añade un objeto `Tabla` a la base de datos.
157
+
158
+ - Args:
159
+ - `tabla` (Tabla): Objeto que representa una tabla definida por el usuario.
160
+ """
161
+ self.tablas[tabla.nombre] = tabla
162
+
163
+ def get_tabla(self, nombre: str) -> Tabla:
164
+ """
165
+ Recupera un objeto `Tabla` anteriormente añadido (o lanza KeyError si no existe).
166
+ """
167
+ if nombre not in self.tablas:
168
+ raise KeyError(f"La tabla '{nombre}' no está registrada.")
169
+ return self.tablas[nombre]
170
+
171
+ async def query_multi_action(self, sql: str, lista_valores: List[tuple], conexion=None) -> int:
172
+ """
173
+ Ejecuta múltiples acciones SQL con la misma sentencia y diferentes valores usando `executemany`.
174
+
175
+ - Si se pasa `conexion`, NO se hace commit ni rollback aquí (compatibilidad con transacciones externas).
176
+ - Si NO se pasa `conexion`, se abre una conexión propia (vía _PseudoPool.acquire()) y se hace commit.
177
+
178
+ Nota: a diferencia de MariaDB, Postgres no tiene un `lastrowid` global tras un `executemany`,
179
+ así que este método no actualiza `self.ultimo_insert_id`.
180
+ """
181
+ if not lista_valores:
182
+ return 0
183
+
184
+ if conexion is not None:
185
+ async with conexion.cursor() as cursor:
186
+ await cursor.executemany(sql, lista_valores)
187
+ return cursor.rowcount
188
+ else:
189
+ if self.pool is None:
190
+ await self.conectar()
191
+
192
+ async with self.pool.acquire() as cx:
193
+ try:
194
+ async with cx.cursor() as cursor:
195
+ await cursor.executemany(sql, lista_valores)
196
+ total_filas = cursor.rowcount
197
+ await cx.commit()
198
+ return total_filas
199
+ except Exception:
200
+ try:
201
+ await cx.rollback()
202
+ except Exception:
203
+ pass
204
+ raise
205
+
206
+ async def query(self, sql: str, params=None, conexion=None, uno: bool = False):
207
+ """
208
+ Ejecuta una consulta SQL detectando si es de lectura o de acción según la primera palabra del SQL.
209
+
210
+ Si comienza por SELECT/SHOW/EXPLAIN → delega en `_query_select`.
211
+ En caso contrario → delega en `_query_accion`.
212
+
213
+ Gestión de conexión:
214
+ - Si `conexion` es None, abre y cierra conexión internamente y gestiona commit/rollback cuando proceda.
215
+ - Si `conexion` no es None, reutiliza la transacción/conn proporcionada y NO hace commit/rollback.
216
+
217
+ Args:
218
+ sql (str): Sentencia SQL completa.
219
+ params (Any, opcional): Parámetros de la consulta (tuple/list/dict según driver).
220
+ conexion (Any, opcional): Conexión o transacción activa a reutilizar.
221
+ uno (bool, opcional): Solo para lecturas; si True retorna una sola fila.
222
+
223
+ Returns:
224
+ Any:
225
+ - Lectura: lista de filas o una sola fila si `uno=True` (formato según `_query_select`).
226
+ - Acción: resultado/contador según `_query_accion` (filas afectadas, y `ultimo_insert_id` si hay RETURNING).
227
+
228
+ Raises:
229
+ Exception: Re-lanza errores del driver/adapter subyacente.
230
+ """
231
+
232
+ comando = sql.strip().split()[0].upper() if sql else ""
233
+ if comando in {"SELECT", "SHOW", "EXPLAIN"}:
234
+ return await self._query_select(sql, params, conexion, uno)
235
+ else:
236
+ return await self._query_accion(sql, params, conexion)
237
+
238
+ async def _query_select(self, sql: str, params=None, conexion=None, uno: bool = False) -> Union[List[Dict], Dict, None]:
239
+ """
240
+ SELECT/SHOW/etc. Si `conexion` es None, usa una conexión propia vía _PseudoPool.
241
+ Devuelve el número de filas obtenidas
242
+ """
243
+ if conexion is not None:
244
+ async with conexion.cursor(row_factory=dict_row) as cursor:
245
+ await cursor.execute(sql, params)
246
+ if uno:
247
+ return await cursor.fetchone()
248
+ return await cursor.fetchall()
249
+ else:
250
+ if self.pool is None:
251
+ await self.conectar()
252
+ async with self.pool.acquire() as cx:
253
+ async with cx.cursor(row_factory=dict_row) as cursor:
254
+ await cursor.execute(sql, params)
255
+ if uno:
256
+ return await cursor.fetchone()
257
+ return await cursor.fetchall()
258
+
259
+ async def _query_accion(self, sql: str, params=None, conexion=None) -> int:
260
+ """
261
+ INSERT/UPDATE/DELETE/etc. Postgres no tiene `lastrowid`: si la sentencia incluye una
262
+ cláusula RETURNING, el primer valor de la primera fila devuelta se guarda en
263
+ `self.ultimo_insert_id`. Devuelve el número de filas afectadas.
264
+ """
265
+ if conexion is not None:
266
+ async with conexion.cursor() as cursor:
267
+ await cursor.execute(sql, params)
268
+ filas = cursor.rowcount
269
+ if cursor.description:
270
+ fila = await cursor.fetchone()
271
+ if fila:
272
+ self.ultimo_insert_id = fila[0]
273
+ return filas
274
+ else:
275
+ if self.pool is None:
276
+ await self.conectar()
277
+
278
+ async with self.pool.acquire() as cx:
279
+ try:
280
+ async with cx.cursor() as cursor:
281
+ await cursor.execute(sql, params)
282
+ filas = cursor.rowcount
283
+ if cursor.description:
284
+ fila = await cursor.fetchone()
285
+ if fila:
286
+ self.ultimo_insert_id = fila[0]
287
+ await cx.commit()
288
+ return filas
289
+ except Exception as e:
290
+ try:
291
+ await cx.rollback()
292
+ except Exception:
293
+ pass
294
+ if self.logger:
295
+ self.logger.error("Error en _query_accion: %s", e)
296
+ raise
297
+
298
+ async def call_proc(self, nombre: str, parametros: tuple = (), conexion=None, uno: bool = True) -> Optional[Union[dict, List[dict]]]:
299
+ """
300
+ Ejecuta un procedimiento almacenado.
301
+ - Si devuelve SELECT, retorna dict/list; si no devuelve nada, None.
302
+ - Si `conexion` es None, abre/cierra conexión propia.
303
+ """
304
+ sql = f"CALL {nombre}({', '.join(['%s'] * len(parametros))})" if parametros else f"CALL {nombre}()"
305
+
306
+ if conexion is not None:
307
+ async with conexion.cursor(row_factory=dict_row) as cursor:
308
+ await cursor.execute(sql, parametros)
309
+ try:
310
+ return await cursor.fetchone() if uno else await cursor.fetchall()
311
+ except Exception:
312
+ return None
313
+ else:
314
+ if self.pool is None:
315
+ await self.conectar()
316
+ async with self.pool.acquire() as cx:
317
+ try:
318
+ async with cx.cursor(row_factory=dict_row) as cursor:
319
+ await cursor.execute(sql, parametros)
320
+ try:
321
+ resultado = await cursor.fetchone() if uno else await cursor.fetchall()
322
+ except Exception:
323
+ resultado = None
324
+ await cx.commit()
325
+ return resultado
326
+ except Exception as e:
327
+ try:
328
+ await cx.rollback()
329
+ except Exception:
330
+ pass
331
+ if self.logger:
332
+ self.logger.error("Error en call_proc('%s'): %s", nombre, e)
333
+ raise