mtpk-postgres 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 José Jesús Andrés Zambrana
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: mtpk_postgres
3
+ Version: 0.1.0
4
+ Summary: Librería para sincronización estructural de bases de datos Postgres con modelos Python (adaptación de mtpk_mariadb)
5
+ Author-email: José Jesús Andrés Zambrana <jjandres@multiplika.es>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jjandres/mtpk_postgres
8
+ Project-URL: Repository, https://github.com/jjandres/mtpk_postgres
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Database
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: psycopg[binary,pool]
17
+ Requires-Dist: pydantic
18
+ Requires-Dist: bcrypt
19
+ Dynamic: license-file
20
+
21
+ # mtpk_postgres
22
+
23
+ Adaptación a Postgres (vía [psycopg 3](https://www.psycopg.org/psycopg3/)) de `mtpk_mariadb`: define tus tablas con
24
+ dataclasses (`Tabla`, `Columna`, `ForeignKey`, `Index`), sincroniza la estructura de la base de datos automáticamente
25
+ (CREATE/ALTER TABLE, índices, claves foráneas) y usa `AsyncCrudBase` para CRUD asíncrono.
26
+
27
+ No modifica ni depende de `mtpk_mariadb`; es un paquete independiente. Ver `docs/mtpk-postgres-adaptacion.md` en el
28
+ repo para el detalle de las diferencias frente a la versión MariaDB.
29
+
30
+ ## Instalación
31
+
32
+ ```
33
+ pip install -e .
34
+ ```
35
+
36
+ ## Notas de la adaptación
37
+
38
+ - Identificadores citados con `"comillas dobles"` en vez de backticks.
39
+ - `auto_increment=True` genera `GENERATED ALWAYS AS IDENTITY` en vez de `AUTO_INCREMENT`; como Postgres no tiene
40
+ `lastrowid`, las inserciones vía `AsyncCrudBase.insert()`/`insertar()` añaden `RETURNING id`.
41
+ - `Tabla.to_sql()` ya no incluye índices ni comentarios inline (Postgres no lo permite dentro de `CREATE TABLE`):
42
+ usa `Tabla.to_sql_indices()` y `Tabla.to_sql_comentarios()` para las sentencias `CREATE INDEX` y `COMMENT ON`
43
+ correspondientes. `ManagerDB` ya las ejecuta automáticamente al crear tablas.
44
+ - `ENUM`/`SET` se traducen a `TEXT` + `CHECK`; al reconstruir la estructura desde la base de datos ambos se
45
+ reportan como `ENUM` (Postgres no permite distinguirlos después del hecho).
46
+ - A diferencia de MariaDB, el DDL en Postgres es transaccional: los métodos de `ManagerDB` confirman (`COMMIT`)
47
+ explícitamente tras crear tablas, triggers y procedimientos.
@@ -0,0 +1,27 @@
1
+ # mtpk_postgres
2
+
3
+ Adaptación a Postgres (vía [psycopg 3](https://www.psycopg.org/psycopg3/)) de `mtpk_mariadb`: define tus tablas con
4
+ dataclasses (`Tabla`, `Columna`, `ForeignKey`, `Index`), sincroniza la estructura de la base de datos automáticamente
5
+ (CREATE/ALTER TABLE, índices, claves foráneas) y usa `AsyncCrudBase` para CRUD asíncrono.
6
+
7
+ No modifica ni depende de `mtpk_mariadb`; es un paquete independiente. Ver `docs/mtpk-postgres-adaptacion.md` en el
8
+ repo para el detalle de las diferencias frente a la versión MariaDB.
9
+
10
+ ## Instalación
11
+
12
+ ```
13
+ pip install -e .
14
+ ```
15
+
16
+ ## Notas de la adaptación
17
+
18
+ - Identificadores citados con `"comillas dobles"` en vez de backticks.
19
+ - `auto_increment=True` genera `GENERATED ALWAYS AS IDENTITY` en vez de `AUTO_INCREMENT`; como Postgres no tiene
20
+ `lastrowid`, las inserciones vía `AsyncCrudBase.insert()`/`insertar()` añaden `RETURNING id`.
21
+ - `Tabla.to_sql()` ya no incluye índices ni comentarios inline (Postgres no lo permite dentro de `CREATE TABLE`):
22
+ usa `Tabla.to_sql_indices()` y `Tabla.to_sql_comentarios()` para las sentencias `CREATE INDEX` y `COMMENT ON`
23
+ correspondientes. `ManagerDB` ya las ejecuta automáticamente al crear tablas.
24
+ - `ENUM`/`SET` se traducen a `TEXT` + `CHECK`; al reconstruir la estructura desde la base de datos ambos se
25
+ reportan como `ENUM` (Postgres no permite distinguirlos después del hecho).
26
+ - A diferencia de MariaDB, el DDL en Postgres es transaccional: los métodos de `ManagerDB` confirman (`COMMIT`)
27
+ explícitamente tras crear tablas, triggers y procedimientos.
@@ -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