ecodev-core 0.0.56__tar.gz → 0.0.58__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.

Potentially problematic release.


This version of ecodev-core might be problematic. Click here for more details.

Files changed (33) hide show
  1. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/PKG-INFO +1 -1
  2. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/__init__.py +2 -1
  3. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/db_connection.py +1 -1
  4. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/db_upsertion.py +189 -0
  5. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/pyproject.toml +1 -1
  6. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/LICENSE.md +0 -0
  7. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/README.md +0 -0
  8. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/app_activity.py +0 -0
  9. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/app_rights.py +0 -0
  10. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/app_user.py +0 -0
  11. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/auth_configuration.py +0 -0
  12. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/authentication.py +0 -0
  13. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/backup.py +0 -0
  14. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/check_dependencies.py +0 -0
  15. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/custom_equal.py +0 -0
  16. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/db_filters.py +0 -0
  17. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/db_insertion.py +0 -0
  18. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/db_retrieval.py +0 -0
  19. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/deployment.py +0 -0
  20. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/email_sender.py +0 -0
  21. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/enum_utils.py +0 -0
  22. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/es_connection.py +0 -0
  23. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/list_utils.py +0 -0
  24. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/logger.py +0 -0
  25. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/pandas_utils.py +0 -0
  26. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/permissions.py +0 -0
  27. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/pydantic_utils.py +0 -0
  28. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/read_write.py +0 -0
  29. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/safe_utils.py +0 -0
  30. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/settings.py +0 -0
  31. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/sqlmodel_utils.py +0 -0
  32. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/token_banlist.py +0 -0
  33. {ecodev_core-0.0.56 → ecodev_core-0.0.58}/ecodev_core/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ecodev-core
3
- Version: 0.0.56
3
+ Version: 0.0.58
4
4
  Summary: Low level sqlmodel/fastapi/pydantic building blocks
5
5
  License: MIT
6
6
  Author: Thomas Epelbaum
@@ -11,7 +11,7 @@ from ecodev_core.app_user import AppUser
11
11
  from ecodev_core.app_user import select_user
12
12
  from ecodev_core.app_user import upsert_app_users
13
13
  from ecodev_core.auth_configuration import AUTH
14
- from ecodev_core.authentication import attempt_to_log, is_banned
14
+ from ecodev_core.authentication import attempt_to_log
15
15
  from ecodev_core.authentication import ban_token
16
16
  from ecodev_core.authentication import get_access_token
17
17
  from ecodev_core.authentication import get_app_services
@@ -19,6 +19,7 @@ from ecodev_core.authentication import get_current_user
19
19
  from ecodev_core.authentication import get_user
20
20
  from ecodev_core.authentication import is_admin_user
21
21
  from ecodev_core.authentication import is_authorized_user
22
+ from ecodev_core.authentication import is_banned
22
23
  from ecodev_core.authentication import is_monitoring_user
23
24
  from ecodev_core.authentication import JwtAuth
24
25
  from ecodev_core.authentication import safe_get_user
@@ -36,7 +36,7 @@ _PASSWORD = quote(SETTINGS_DB.db_password or DB.db_password, safe='')
36
36
  _USER, _HOST = SETTINGS_DB.db_username or DB.db_username, SETTINGS_DB.db_host or DB.db_host
37
37
  _PORT, _NAME = SETTINGS_DB.db_port or DB.db_port, SETTINGS_DB.db_name or DB.db_name
38
38
  DB_URL = f'postgresql://{_USER}:{_PASSWORD}@{_HOST}:{_PORT}/{_NAME}'
39
- engine = create_engine(DB_URL)
39
+ engine = create_engine(DB_URL, pool_pre_ping=True)
40
40
 
41
41
 
42
42
  def create_db_and_tables(model: Callable, excluded_tables: Optional[List[str]] = None) -> None:
@@ -1,14 +1,21 @@
1
1
  """
2
2
  Module handling CRUD and version operations
3
3
  """
4
+ import enum
5
+ import json
6
+ import types
4
7
  from datetime import datetime
5
8
  from enum import EnumType
6
9
  from functools import partial
7
10
  from typing import Any
11
+ from typing import get_args
12
+ from typing import get_origin
13
+ from typing import Iterator
8
14
  from typing import Union
9
15
 
10
16
  import pandas as pd
11
17
  import progressbar
18
+ from pydantic_core._pydantic_core import PydanticUndefined
12
19
  from sqlmodel import and_
13
20
  from sqlmodel import Field
14
21
  from sqlmodel import inspect
@@ -191,3 +198,185 @@ def filter_to_sfield_dict(row: dict | SQLModelMetaclass,
191
198
  """
192
199
  return {pk: getattr(row, pk)
193
200
  for pk in get_sfield_columns(db_schema or row.__class__)}
201
+
202
+
203
+ def add_missing_columns(model: Any, session: Session) -> None:
204
+ """
205
+ Create all columns corresponding to fields in the passed model that are not yet columns in the
206
+ corresponding db table.
207
+
208
+ NB: The ORM not permitting to create new columns, we unfortunately have to rely on sqlalchemy
209
+ text sql statements.
210
+
211
+ NB2: As of 2025/10/01, handle the creation of int, float, str, bool, bytes, JSONB, Enum columns
212
+
213
+ NB3: possible to index columns, and to add foreign key.
214
+
215
+ NB4: Possible to have a non NULL default value
216
+ """
217
+ table = model.__tablename__
218
+ current_cols, = get_existing_columns(table, session),
219
+ for col, py_type, fld in [(c, p, f) for c, p, f in _get_cols(model) if c not in current_cols]:
220
+ is_null = _is_type_nullable(py_type)
221
+ default = _get_default_value(fld, is_null)
222
+ _add_column(table, col, _py_type_to_sql(_clean_py_type(py_type)), default, is_null, session)
223
+ if getattr(fld, 'index', False):
224
+ _add_index(table, col, session)
225
+ if isinstance((fk := getattr(fld, 'foreign_key', None)), str) and fk.strip():
226
+ _add_foreign_key(f"{fk.split('.')[0]}(id)", table, col, session)
227
+ session.commit()
228
+
229
+
230
+ def _get_default_value(fld: Any, nullable: bool) -> Any:
231
+ """
232
+ Find if any the field default value
233
+ """
234
+ if not nullable and hasattr(fld, 'default') and fld.default is not None:
235
+ return fld.default
236
+ return None
237
+
238
+
239
+ def _add_column(table: str,
240
+ col: str,
241
+ sql_type: str,
242
+ default: Any,
243
+ nullable: bool,
244
+ session: Session
245
+ ) -> None:
246
+ """
247
+ Add the new column with sql_type to the passed table
248
+ """
249
+ session.execute(text(f'ALTER TABLE {table} ADD COLUMN {col} {sql_type} '
250
+ f'{_get_additional_request(col, sql_type, default, nullable)}'))
251
+
252
+
253
+ def _get_additional_request(col: str, sql_type: str, default_value: Any, nullable: bool) -> str:
254
+ """
255
+ Add if any the default value for the passed col.
256
+ """
257
+ if nullable:
258
+ return 'NULL'
259
+
260
+ if default_value is not None:
261
+ if (default_sql := _python_default_to_sql(default_value, sql_type)) == 'NULL':
262
+ raise ValueError(f'Non-nullable column {col} requires a default_value')
263
+ return f'DEFAULT {default_sql} NOT NULL'
264
+
265
+ raise ValueError(f'Non-nullable column {col} requires a default_value')
266
+
267
+
268
+ def _add_index(table: str, col: str, session: Session):
269
+ """
270
+ Index the new table column
271
+ """
272
+ session.execute(text(f'CREATE INDEX IF NOT EXISTS ix_{table}_{col} ON {table} ({col})'))
273
+
274
+
275
+ def _add_foreign_key(fk: str, table: str, col: str, session: Session):
276
+ """
277
+ Add a fk foreign key on the passed table column
278
+ """
279
+ session.execute(text(
280
+ f'ALTER TABLE {table} ADD CONSTRAINT fk_{table}_{col} FOREIGN KEY ({col}) REFERENCES {fk}'))
281
+
282
+
283
+ def _get_cols(model: Any) -> Iterator[tuple[str, Any, Any]]:
284
+ """
285
+ Retrieve all fields and their corresponding sql types from the passed model
286
+ """
287
+ for col, field in model.model_fields.items():
288
+ if (col_type := getattr(field, 'annotation', None)) is not None:
289
+ yield col, col_type, field
290
+
291
+
292
+ def get_existing_columns(table_name: str, session: Session) -> set[str]:
293
+ """
294
+ Retrieve all column names from the passed table
295
+ """
296
+ result = session.execute(text('SELECT column_name FROM information_schema.columns WHERE '
297
+ 'table_name = :table_name'), {'table_name': table_name})
298
+ return {r[0] for r in result}
299
+
300
+
301
+ def _clean_py_type(col_type: Any) -> Any:
302
+ """
303
+ Convert union and optional types to their non-None types, return directly passed type otherwise.
304
+ - Handle Python 3.10+ UnionType (aka X | Y)
305
+ - Unpack Optional types (Union[X, NoneType])
306
+ """
307
+ if isinstance(col_type, types.UnionType):
308
+ if len((args := [t for t in col_type.__args__ if t is not type(None)])) == 1:
309
+ return args[0]
310
+
311
+ if get_origin(col_type) is Union:
312
+ if len((args := [t for t in get_args(col_type) if t is not type(None)])) == 1:
313
+ return args[0]
314
+
315
+ return col_type
316
+
317
+
318
+ def _is_type_nullable(col_type: Any) -> bool:
319
+ """
320
+ Return True if col_type is Optional or Union[..., None].
321
+ """
322
+ if isinstance(col_type, types.UnionType):
323
+ return type(None) in col_type.__args__
324
+
325
+ if get_origin(col_type) is Union:
326
+ return type(None) in get_args(col_type)
327
+
328
+ return col_type is type(None)
329
+
330
+
331
+ def _python_default_to_sql(value: Any, sql_type: str) -> str:
332
+ """
333
+ Convert Python default to SQL literal, handling common types.
334
+ """
335
+ if value is None or value == PydanticUndefined:
336
+ return 'NULL'
337
+ if sql_type in ('VARCHAR', 'TEXT', 'CHAR'):
338
+ safe_value = value.replace("'", "''")
339
+ return f"'{safe_value}'"
340
+ if sql_type in ('INTEGER', 'FLOAT', 'NUMERIC', 'DOUBLE PRECISION'):
341
+ return str(value)
342
+ if sql_type == 'BOOLEAN':
343
+ return 'TRUE' if value else 'FALSE'
344
+ if sql_type == 'BYTEA':
345
+ if isinstance(value, bytes):
346
+ return f"decode('{value.hex()}', 'hex')"
347
+ raise ValueError('Default for BYTEA must be bytes')
348
+ if sql_type == 'JSONB':
349
+ json_str = json.dumps(value).replace("'", "''")
350
+ return f"'{json_str}'::jsonb"
351
+ if isinstance(value, enum.Enum):
352
+ return f"'{str(value.name)}'"
353
+ return str(value)
354
+
355
+
356
+ def _py_type_to_sql(col_type: type) -> str:
357
+ """
358
+ Convert a python type to a sql one. Only working for (as of 2025/10/01):
359
+ - int
360
+ - float
361
+ - str
362
+ - bool
363
+ - bytes
364
+ - jsonB
365
+ - Enum
366
+ NB: for enum, assumes type is already created in DB
367
+ """
368
+ if col_type is str:
369
+ return 'VARCHAR'
370
+ if col_type is int:
371
+ return 'INTEGER'
372
+ if col_type is float:
373
+ return 'FLOAT'
374
+ if col_type is bool:
375
+ return 'BOOLEAN'
376
+ if col_type is bytes:
377
+ return 'BYTEA'
378
+ if col_type is dict:
379
+ return 'JSONB'
380
+ if hasattr(col_type, '__members__'):
381
+ return col_type.__name__.lower()
382
+ raise ValueError(f'Unsupported column type: {col_type}')
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "ecodev-core"
3
- version = "0.0.56"
3
+ version = "0.0.58"
4
4
  description = "Low level sqlmodel/fastapi/pydantic building blocks"
5
5
  authors = ["Thomas Epelbaum <tomepel@gmail.com>",
6
6
  "Olivier Gabriel <olivier.gabriel.geom@gmail.com>",
File without changes
File without changes