lcdp-sqlalchemy-utils 1.5.8__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.
File without changes
@@ -0,0 +1,64 @@
1
+ from logging.config import fileConfig
2
+
3
+ from sqlalchemy import engine_from_config
4
+ from sqlalchemy import pool
5
+
6
+ from alembic import context
7
+
8
+ # this is the Alembic Config object, which provides
9
+ # access to the values within the .ini file in use.
10
+ config = context.config
11
+
12
+ # Interpret the config file for Python logging.
13
+ # This line sets up loggers basically.
14
+ if config.config_file_name is not None:
15
+ fileConfig(config.config_file_name)
16
+
17
+
18
+ def run_migrations_offline(**kwargs):
19
+ """Run migrations in 'offline' mode.
20
+
21
+ This configures the context with just a URL
22
+ and not an Engine, though an Engine is acceptable
23
+ here as well. By skipping the Engine creation
24
+ we don't even need a DBAPI to be available.
25
+
26
+ Calls to context.execute() here emit the given string to the
27
+ script output.
28
+
29
+ """
30
+ url = config.get_main_option("sqlalchemy.url")
31
+ context.configure(
32
+ url=url,
33
+ literal_binds=True,
34
+ dialect_opts={"paramstyle": "named"},
35
+ **kwargs
36
+ )
37
+
38
+ with context.begin_transaction():
39
+ context.run_migrations()
40
+
41
+
42
+ def run_migrations_online(db_url=None, **kwargs):
43
+ """Run migrations in 'online' mode.
44
+
45
+ In this scenario we need to create an Engine
46
+ and associate a connection with the context.
47
+
48
+ """
49
+ alembic_config = config.get_section(config.config_ini_section)
50
+ connectable = engine_from_config(
51
+ alembic_config,
52
+ url=db_url,
53
+ prefix="sqlalchemy.",
54
+ poolclass=pool.NullPool,
55
+ )
56
+
57
+ with connectable.connect() as connection:
58
+ # for compare_type=True, https://alembic.sqlalchemy.org/en/latest/autogenerate.html#comparing-types
59
+ context.configure(
60
+ connection=connection, compare_type=True, **kwargs
61
+ )
62
+
63
+ with context.begin_transaction():
64
+ context.run_migrations()
@@ -0,0 +1,73 @@
1
+ import asyncio
2
+ from logging.config import fileConfig
3
+
4
+ from sqlalchemy import engine_from_config
5
+ from sqlalchemy import pool
6
+
7
+ from alembic import context
8
+ from sqlalchemy.engine import Connection
9
+ from sqlalchemy.ext.asyncio import AsyncEngine
10
+
11
+ # this is the Alembic Config object, which provides
12
+ # access to the values within the .ini file in use.
13
+ config = context.config
14
+
15
+ # Interpret the config file for Python logging.
16
+ # This line sets up loggers basically.
17
+ if config.config_file_name is not None:
18
+ fileConfig(config.config_file_name)
19
+
20
+
21
+ async def run_migrations_offline(**kwargs):
22
+ """Run migrations in 'offline' mode.
23
+
24
+ This configures the context with just a URL
25
+ and not an Engine, though an Engine is acceptable
26
+ here as well. By skipping the Engine creation
27
+ we don't even need a DBAPI to be available.
28
+
29
+ Calls to context.execute() here emit the given string to the
30
+ script output.
31
+
32
+ """
33
+ url = config.get_main_option("sqlalchemy.url")
34
+ context.configure(
35
+ url=url,
36
+ literal_binds=True,
37
+ dialect_opts={"paramstyle": "named"},
38
+ **kwargs
39
+ )
40
+
41
+ async with context.begin_transaction():
42
+ context.run_migrations()
43
+
44
+
45
+ def do_run_migrations(connection: Connection, **kwargs) -> None:
46
+ # target_metadata = MetaData()
47
+ context.configure(connection=connection, compare_type=True, **kwargs)
48
+
49
+ with context.begin_transaction():
50
+ context.run_migrations()
51
+
52
+
53
+ async def run_migrations_online(db_url=None, **kwargs):
54
+ """Run migrations in 'online' mode.
55
+
56
+ In this scenario we need to create an Engine
57
+ and associate a connection with the context.
58
+
59
+ """
60
+ alembic_config = config.get_section(config.config_ini_section)
61
+ connectable = AsyncEngine(
62
+ engine_from_config(
63
+ alembic_config,
64
+ url=db_url,
65
+ prefix="sqlalchemy.",
66
+ poolclass=pool.NullPool,
67
+ future=True,
68
+ )
69
+ )
70
+
71
+ async with connectable.connect() as connection:
72
+ await connection.run_sync(do_run_migrations, **kwargs)
73
+ await connectable.dispose()
@@ -0,0 +1,3 @@
1
+ # Raise this when arguments send from the client are invalid
2
+ class InvalidOrderByException(Exception):
3
+ pass
File without changes
@@ -0,0 +1,11 @@
1
+ import os
2
+
3
+ from alembic.config import Config
4
+ from alembic import command
5
+
6
+ # Upgrade migration
7
+ # see https://alembic.sqlalchemy.org/en/latest/api/config.html
8
+ def launch_migration_upgrade(alembic_config_path, db_url):
9
+ alembic_cfg = Config(alembic_config_path)
10
+ alembic_cfg.set_main_option("sqlalchemy.url", db_url)
11
+ command.upgrade(alembic_cfg, "head")
@@ -0,0 +1,26 @@
1
+ from sqlalchemy import desc, asc
2
+ from .exceptions.InvalidOrderByException import InvalidOrderByException
3
+
4
+
5
+ def build(order_by_dto, dao_mapper):
6
+ if order_by_dto is None:
7
+ return None
8
+
9
+ to = []
10
+ for order_by in order_by_dto:
11
+ try:
12
+ parts = order_by.split(':', 2)
13
+ column = dao_mapper(parts[0])
14
+ to.append(__add_sorting_order(parts[1], column))
15
+ except IndexError as e:
16
+ raise InvalidOrderByException("Order by dto is invalid cannot be convert to sqlalchemy order by", e)
17
+ return to
18
+
19
+
20
+ def __add_sorting_order(sorting_order, column):
21
+ if sorting_order.upper() == 'DESC':
22
+ return desc(column)
23
+ elif sorting_order.upper() == 'ASC':
24
+ return asc(column)
25
+ else:
26
+ raise InvalidOrderByException(f"{sorting_order.upper()} is neither ASC or DESC")
@@ -0,0 +1,214 @@
1
+ import os
2
+ from datetime import datetime, timezone, date
3
+
4
+ import sqlalchemy as sa
5
+ import sqlalchemy.types as types
6
+ from dataclasses_json import dataclass_json
7
+ from dataclasses_json.mm import TYPES, _IsoField
8
+ from sqlalchemy import create_engine
9
+ from sqlalchemy.dialects.postgresql import JSONB
10
+ from sqlalchemy.ext.asyncio import async_scoped_session
11
+ from sqlalchemy.ext.asyncio import async_sessionmaker
12
+ from sqlalchemy.ext.asyncio import create_async_engine
13
+ from sqlalchemy.orm import sessionmaker, scoped_session
14
+
15
+ LOCAL_TIMEZONE = datetime.now(timezone.utc).astimezone().tzinfo
16
+ # See : https://mike.depalatis.net/blog/sqlalchemy-timestamps.html
17
+ class Timestamp(sa.types.TypeDecorator):
18
+ impl = sa.types.DateTime
19
+
20
+ cache_ok = True
21
+
22
+ def process_bind_param(self, value: datetime, dialect):
23
+ if value is None:
24
+ return None
25
+
26
+ if value.tzinfo is None:
27
+ value = value.astimezone(LOCAL_TIMEZONE)
28
+
29
+ value = value.astimezone(timezone.utc)
30
+
31
+ if self.timezone:
32
+ return value
33
+ else:
34
+ return value.replace(tzinfo=None)
35
+
36
+ def process_result_value(self, value, dialect):
37
+ if value is None:
38
+ return None
39
+
40
+ if value.tzinfo is None:
41
+ return value.replace(tzinfo=timezone.utc)
42
+
43
+ return value.astimezone(timezone.utc)
44
+
45
+
46
+ # Customize encoder/decoder to use isoformat to store datetime objects (See : https://github.com/lidatong/dataclasses-json/issues/332)
47
+ TYPES[datetime] = _IsoField
48
+
49
+ def JSONBDataclass(cls, many=False):
50
+ serializable_cls = dataclass_json(cls)
51
+ schema = serializable_cls.schema(many=many)
52
+
53
+ def process_bind_param(self, value, dialect):
54
+ if value is None:
55
+ return value
56
+
57
+ return schema.dump(value)
58
+
59
+ def process_result_value(self, value, dialect):
60
+ if value is None:
61
+ return value
62
+
63
+ return schema.load(value)
64
+
65
+ def coerce_compared_value(self, op, value):
66
+ return self.impl.coerce_compared_value(op, value)
67
+
68
+ return type(cls.__name__, (types.TypeDecorator,), {
69
+ "impl": JSONB,
70
+ "process_bind_param": process_bind_param,
71
+ "process_result_value": process_result_value,
72
+ "cache_ok": True,
73
+ "coerce_compared_value": coerce_compared_value,
74
+ })
75
+
76
+ import json
77
+ def json_serializer(x):
78
+ def default(obj):
79
+ """JSON serializer for objects not serializable by default json code"""
80
+
81
+ if isinstance(obj, (datetime, date)):
82
+ return obj.isoformat()
83
+ raise TypeError("Type %s not serializable" % type(obj))
84
+
85
+ return json.dumps(x, default=default)
86
+
87
+ class PostgresConnector(object):
88
+ def __init__(self, host, db_name, username, password,
89
+ pool_size=None,
90
+ max_overflow=None,
91
+ pool_timeout=None,
92
+ pool_recycle=None):
93
+ """
94
+ Args:
95
+ host: Database host
96
+ db_name: Database name
97
+ username: Database username
98
+ password: Database password
99
+ pool_size: Number of permanent connections (env: DB_POOL_SIZE, default: 5)
100
+ HikariCP equivalent: minimumIdle = 5
101
+ max_overflow: Max additional connections beyond pool_size (env: DB_MAX_OVERFLOW, default: 5)
102
+ HikariCP equivalent: maximumPoolSize - minimumIdle = 10 - 5 = 5
103
+ pool_timeout: Seconds to wait for a connection (env: DB_POOL_TIMEOUT, default: 30)
104
+ HikariCP equivalent: connectionTimeout (not set in current config, recommended: 30000ms)
105
+ pool_recycle: Seconds before recycling a connection (env: DB_POOL_RECYCLE, default: 480)
106
+ HikariCP equivalent: maxLifetime = 480000ms (8 minutes)
107
+ """
108
+ self.host = host
109
+ self.username = username
110
+ self.password = password
111
+ self.db_name = db_name
112
+ self.pool_size = pool_size if pool_size is not None else int(os.getenv('DB_POOL_SIZE', '5'))
113
+ self.max_overflow = max_overflow if max_overflow is not None else int(os.getenv('DB_MAX_OVERFLOW', '5'))
114
+ self.pool_timeout = pool_timeout if pool_timeout is not None else int(os.getenv('DB_POOL_TIMEOUT', '30'))
115
+ self.pool_recycle = pool_recycle if pool_recycle is not None else int(os.getenv('DB_POOL_RECYCLE', '480'))
116
+ self._engine = None
117
+ self._session_factory = None
118
+
119
+ def get_engine(self):
120
+ if not self._engine:
121
+ # https://docs.sqlalchemy.org/en/14/core/pooling.html#dealing-with-disconnects
122
+ self._engine = create_engine(self.get_url(),
123
+ pool_pre_ping=True,
124
+ pool_size=self.pool_size,
125
+ max_overflow=self.max_overflow,
126
+ pool_timeout=self.pool_timeout,
127
+ pool_recycle=self.pool_recycle,
128
+ pool_use_lifo=True,
129
+ connect_args={},
130
+ json_serializer=json_serializer
131
+ )
132
+
133
+ return self._engine
134
+
135
+ def get_scoped_session(self, _ident_func):
136
+ return scoped_session(self.get_session_factory(), scopefunc=_ident_func)
137
+
138
+ def get_session_factory(self):
139
+ if not self._session_factory:
140
+ self._session_factory = sessionmaker(bind=self.get_engine())
141
+ return self._session_factory
142
+
143
+ def get_url(self) -> str:
144
+ return "postgresql+psycopg2://{}:{}@{}/{}".format(
145
+ self.username,
146
+ self.password,
147
+ self.host,
148
+ self.db_name
149
+ )
150
+
151
+
152
+ class AsyncPostgresConnector(object):
153
+ def __init__(self, host, db_name, username, password,
154
+ pool_size=None,
155
+ max_overflow=None,
156
+ pool_timeout=None,
157
+ pool_recycle=None):
158
+ """
159
+ Args:
160
+ host: Database host
161
+ db_name: Database name
162
+ username: Database username
163
+ password: Database password
164
+ pool_size: Number of permanent connections (env: DB_POOL_SIZE, default: 5)
165
+ HikariCP equivalent: minimumIdle = 5
166
+ max_overflow: Max additional connections beyond pool_size (env: DB_MAX_OVERFLOW, default: 5)
167
+ HikariCP equivalent: maximumPoolSize - minimumIdle = 10 - 5 = 5
168
+ pool_timeout: Seconds to wait for a connection (env: DB_POOL_TIMEOUT, default: 30)
169
+ HikariCP equivalent: connectionTimeout (not set in current config, recommended: 30000ms)
170
+ pool_recycle: Seconds before recycling a connection (env: DB_POOL_RECYCLE, default: 480)
171
+ HikariCP equivalent: maxLifetime = 480000ms (8 minutes)
172
+ """
173
+ self.host = host
174
+ self.username = username
175
+ self.password = password
176
+ self.db_name = db_name
177
+ self.pool_size = pool_size if pool_size is not None else int(os.getenv('DB_POOL_SIZE', '5'))
178
+ self.max_overflow = max_overflow if max_overflow is not None else int(os.getenv('DB_MAX_OVERFLOW', '5'))
179
+ self.pool_timeout = pool_timeout if pool_timeout is not None else int(os.getenv('DB_POOL_TIMEOUT', '30'))
180
+ self.pool_recycle = pool_recycle if pool_recycle is not None else int(os.getenv('DB_POOL_RECYCLE', '480'))
181
+ self._engine = None
182
+ self._session_factory = None
183
+
184
+ def get_engine(self):
185
+ if not self._engine:
186
+ # https://docs.sqlalchemy.org/en/14/core/pooling.html#dealing-with-disconnects
187
+ self._engine = create_async_engine(self.get_url(),
188
+ pool_pre_ping=True,
189
+ pool_size=self.pool_size,
190
+ max_overflow=self.max_overflow,
191
+ pool_timeout=self.pool_timeout,
192
+ pool_recycle=self.pool_recycle,
193
+ pool_use_lifo=True,
194
+ connect_args={},
195
+ json_serializer=json_serializer
196
+ )
197
+
198
+ return self._engine
199
+
200
+ def get_scoped_session(self, _ident_func):
201
+ return async_scoped_session(self.get_session_factory(), scopefunc=_ident_func)
202
+
203
+ def get_session_factory(self):
204
+ if not self._session_factory:
205
+ self._session_factory = async_sessionmaker(bind=self.get_engine())
206
+ return self._session_factory
207
+
208
+ def get_url(self) -> str:
209
+ return "postgresql+asyncpg://{}:{}@{}/{}".format(
210
+ self.username,
211
+ self.password,
212
+ self.host,
213
+ self.db_name
214
+ )
@@ -0,0 +1,116 @@
1
+ from sqlalchemy import func
2
+ import asyncio
3
+ import nest_asyncio
4
+
5
+ # Avoid RuntimeError: This event loop is already running
6
+ # https://github.com/erdewit/nest_asyncio
7
+ nest_asyncio.apply()
8
+
9
+
10
+ class SearchResult:
11
+ _items: list
12
+ _total_found: int
13
+ _total_visible: int
14
+
15
+ def __init__(self, items=None, total_visible=None, total_found=None):
16
+ self._items = items
17
+ self._total_visible = total_visible
18
+ self._total_found = total_found
19
+
20
+ @property
21
+ def items(self):
22
+ return self._items
23
+
24
+ @items.setter
25
+ def items(self, items):
26
+ self._items = items
27
+
28
+ @property
29
+ def total_visible(self):
30
+ return self._total_visible
31
+
32
+ @total_visible.setter
33
+ def total_visible(self, total_visible):
34
+ self._total_visible = total_visible
35
+
36
+ @property
37
+ def total_found(self):
38
+ return self._total_found
39
+
40
+ @total_found.setter
41
+ def total_found(self, total_found):
42
+ self._total_found = total_found
43
+
44
+
45
+ class QueryExecutor:
46
+ def __init__(self, model):
47
+ self._model = model
48
+
49
+ async def __get_query_count(self, query):
50
+ counter = query.statement.with_only_columns(func.count()).select_from(self._model).order_by(None)
51
+ return query.session.execute(counter).scalar()
52
+
53
+ @staticmethod
54
+ async def __get_query_result(query):
55
+ return query.all()
56
+
57
+ async def __execute(self, query, order_bys, page, limit):
58
+ if not page:
59
+ page = 0
60
+ if not order_bys:
61
+ order_bys = []
62
+
63
+ count_query = query
64
+ if order_bys:
65
+ query = query.order_by(*order_bys)
66
+
67
+ if limit:
68
+ query = query.limit(limit).offset(page * limit)
69
+
70
+ async with asyncio.TaskGroup() as tg:
71
+ items_task = tg.create_task(self.__get_query_result(query))
72
+ count_task = tg.create_task(self.__get_query_count(count_query))
73
+ return SearchResult(items_task.result(), count_task.result(), count_task.result())
74
+
75
+ def execute(self, query, order_bys=None, page=None, limit=None):
76
+ return asyncio.run(self.__execute(query, order_bys, page, limit))
77
+
78
+
79
+ class QueryExecutorAsync:
80
+ def __init__(self, model):
81
+ self._model = model
82
+
83
+ async def __get_query_count(self, get_db_session, query):
84
+ counter = query.with_only_columns(func.count()).select_from(self._model).order_by(None)
85
+ result = await get_db_session().execute(counter)
86
+ return result.scalar_one()
87
+
88
+ @staticmethod
89
+ async def __get_query_result(get_db_session, query):
90
+ result = await get_db_session().execute(query)
91
+ return result.scalars().all()
92
+
93
+ async def __execute(self, get_db_session, query, order_bys, page, limit):
94
+ if not page:
95
+ page = 0
96
+ if not order_bys:
97
+ order_bys = []
98
+
99
+ count_query = query
100
+ if order_bys:
101
+ query = query.order_by(*order_bys)
102
+
103
+ if limit:
104
+ query = query.limit(limit).offset(page * limit)
105
+
106
+ async with asyncio.TaskGroup() as tg:
107
+ count_task = tg.create_task(self.__get_query_count(get_db_session, count_query))
108
+ items = await self.__get_query_result(get_db_session, query)
109
+ # Important note : As count is run in a parallel task, the returned value will not take into account modifications
110
+ # of DB state in the current task session (ex : object have been updated before, without transaction commit)
111
+ await asyncio.shield(count_task)
112
+ return SearchResult(items, count_task.result(), count_task.result())
113
+
114
+ # Pass get_db_session as a method because we need to call two distinct session for counts and values
115
+ async def execute(self, get_db_session, query, order_bys=None, page=None, limit=None):
116
+ return await self.__execute(get_db_session, query, order_bys, page, limit)
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: lcdp-sqlalchemy-utils
3
+ Version: 1.5.8
4
+ Summary: SQLAlchemy Utils
5
+ Author: Le Comptoir Des Pharmacies
6
+ Author-email: webmaster@lecomptoirdespharmacies.fr
7
+ Requires-Python: >=3.8
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.8
10
+ Classifier: Programming Language :: Python :: 3.9
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Dist: SQLAlchemy (==2.0.40)
17
+ Requires-Dist: alembic (==1.8.1)
18
+ Requires-Dist: dataclasses-json (==0.5.7)
19
+ Requires-Dist: nest_asyncio (==1.5.8)
@@ -0,0 +1,12 @@
1
+ lcdp_sqlalchemy_utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ lcdp_sqlalchemy_utils/env.py,sha256=jFtYnrmyLv1k81zTSJtOvPeHCEk61vg-lX7avF5KO3w,1783
3
+ lcdp_sqlalchemy_utils/env_async.py,sha256=r_MdvFNZPGkkR_RwUbAZR-0aNbdKwAGb3vSNidg9_ZM,2133
4
+ lcdp_sqlalchemy_utils/exceptions/InvalidOrderByException.py,sha256=OMmejn2IPHqwgpb7yN3Ihs2p9_UkcMdoVmkWwCVmRZ8,112
5
+ lcdp_sqlalchemy_utils/exceptions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ lcdp_sqlalchemy_utils/migrate.py,sha256=xD0tk1a0qxq_BZl-WGcZybAOZ8fe52dXq7jWAjOIYUI,372
7
+ lcdp_sqlalchemy_utils/order_by_mapper.py,sha256=l3NsH2Uc-0NaHw2neWSY8Zwp2vll89-cBkyLWqIZ-Xo,842
8
+ lcdp_sqlalchemy_utils/postgres.py,sha256=3T0ZU-edagvkKvrd9C8ZZY79e8MjGLF3AuOUvoJL7M4,8842
9
+ lcdp_sqlalchemy_utils/query_executor.py,sha256=tJUPg03non_dpg_Tvk_37H3JpnMu5TfTRqCmtjSYc0w,3820
10
+ lcdp_sqlalchemy_utils-1.5.8.dist-info/METADATA,sha256=K3HQKtFstoTSfY5nDq7UoQhNL7ItrYxqAZfMwg4dc-U,753
11
+ lcdp_sqlalchemy_utils-1.5.8.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
12
+ lcdp_sqlalchemy_utils-1.5.8.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.2.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any