iker-python-common 1.0.61__py3-none-any.whl → 1.0.63__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.
@@ -1,11 +1,13 @@
1
1
  import contextlib
2
2
  import dataclasses
3
- from typing import Any, Sequence
3
+ from typing import Any, Self, Sequence
4
4
 
5
+ import asyncpg
5
6
  import packaging.version
6
7
  import psycopg
7
8
  import pymysql
8
9
  import sqlalchemy
10
+ import sqlalchemy.ext.asyncio
9
11
  import sqlalchemy.ext.compiler
10
12
  import sqlalchemy.orm
11
13
 
@@ -16,6 +18,7 @@ __all__ = [
16
18
  "Drivers",
17
19
  "make_scheme",
18
20
  "ConnectionMaker",
21
+ "AsyncConnectionMaker",
19
22
  "orm_to_dict",
20
23
  "orm_clone",
21
24
  "mysql_insert_ignore",
@@ -31,6 +34,7 @@ class Dialects:
31
34
  class Drivers:
32
35
  pymysql = pymysql.__name__
33
36
  psycopg = psycopg.__name__
37
+ asyncpg = asyncpg.__name__
34
38
 
35
39
 
36
40
  def make_scheme(dialect: str, driver: str | None = None) -> str:
@@ -65,9 +69,10 @@ class ConnectionMaker(object):
65
69
  self.engine_opts = engine_opts or {}
66
70
  self.session_opts = session_opts or {}
67
71
 
68
- @staticmethod
72
+ @classmethod
69
73
  def create(
70
- driver: str | None = None,
74
+ cls,
75
+ scheme: str | None = None,
71
76
  host: str | None = None,
72
77
  port: int | None = None,
73
78
  username: str | None = None,
@@ -76,11 +81,11 @@ class ConnectionMaker(object):
76
81
  *,
77
82
  engine_opts: dict[str, JsonType] | None = None,
78
83
  session_opts: dict[str, JsonType] | None = None,
79
- ):
84
+ ) -> Self:
80
85
  """
81
86
  Creates a new instance of ``ConnectionMaker`` using the provided parameters to construct a SQLAlchemy URL.
82
87
 
83
- :param driver: Optional database driver.
88
+ :param scheme: The database scheme (e.g., 'mysql+pymysql', 'postgresql+psycopg').
84
89
  :param host: The database host (e.g., 'localhost').
85
90
  :param port: The database port.
86
91
  :param username: The database username.
@@ -89,22 +94,23 @@ class ConnectionMaker(object):
89
94
  :param engine_opts: Optional dictionary of SQLAlchemy engine options.
90
95
  :param session_opts: Optional dictionary of SQLAlchemy session options.
91
96
  """
92
- return ConnectionMaker(sqlalchemy.URL.create(drivername=driver,
93
- host=host,
94
- port=port,
95
- username=username,
96
- password=password,
97
- database=database),
98
- engine_opts=engine_opts,
99
- session_opts=session_opts)
100
-
101
- @staticmethod
97
+ return cls(sqlalchemy.URL.create(drivername=scheme,
98
+ host=host,
99
+ port=port,
100
+ username=username,
101
+ password=password,
102
+ database=database),
103
+ engine_opts=engine_opts,
104
+ session_opts=session_opts)
105
+
106
+ @classmethod
102
107
  def from_url(
108
+ cls,
103
109
  url: str | sqlalchemy.URL,
104
110
  *,
105
111
  engine_opts: dict[str, JsonType] | None = None,
106
112
  session_opts: dict[str, JsonType] | None = None,
107
- ) -> "ConnectionMaker":
113
+ ) -> Self:
108
114
  """
109
115
  Creates a new instance of ``ConnectionMaker`` from a SQLAlchemy URL string or object.
110
116
 
@@ -113,7 +119,7 @@ class ConnectionMaker(object):
113
119
  :param session_opts: Optional dictionary of SQLAlchemy session options.
114
120
  :return: A new instance of ``ConnectionMaker`` configured with the provided URL and options.
115
121
  """
116
- return ConnectionMaker(sqlalchemy.make_url(url), engine_opts=engine_opts, session_opts=session_opts)
122
+ return cls(sqlalchemy.make_url(url), engine_opts=engine_opts, session_opts=session_opts)
117
123
 
118
124
  @property
119
125
  def connection_string(self) -> str:
@@ -155,7 +161,6 @@ class ConnectionMaker(object):
155
161
  Creates all tables defined in the given ORM base using the current engine.
156
162
 
157
163
  :param orm_base: The SQLAlchemy ORM base class.
158
- :return: None.
159
164
  """
160
165
  if packaging.version.parse(sqlalchemy.__version__) >= packaging.version.parse("2"):
161
166
  if not isinstance(orm_base, type) or not issubclass(orm_base, sqlalchemy.orm.DeclarativeBase):
@@ -168,7 +173,6 @@ class ConnectionMaker(object):
168
173
  Drops all tables defined in the given ORM base using the current engine.
169
174
 
170
175
  :param orm_base: The SQLAlchemy ORM base class.
171
- :return: None.
172
176
  """
173
177
  if packaging.version.parse(sqlalchemy.__version__) >= packaging.version.parse("2"):
174
178
  if not isinstance(orm_base, type) or not issubclass(orm_base, sqlalchemy.orm.DeclarativeBase):
@@ -182,7 +186,6 @@ class ConnectionMaker(object):
182
186
 
183
187
  :param sql: The SQL statement to execute.
184
188
  :param params: The parameters dictionary for the SQL statement.
185
- :return: None.
186
189
  """
187
190
  with self.make_session() as session:
188
191
  session.execute(sqlalchemy.text(sql), params)
@@ -214,6 +217,104 @@ class ConnectionMaker(object):
214
217
  return result.first()
215
218
 
216
219
 
220
+ class AsyncConnectionMaker(ConnectionMaker):
221
+ """
222
+ Provides utilities to simplify establishing asynchronous database connections and sessions, including connection
223
+ string construction, async engine and session creation, and model management.
224
+ """
225
+
226
+ @property
227
+ def engine(self) -> sqlalchemy.ext.asyncio.AsyncEngine:
228
+ """
229
+ Returns a SQLAlchemy ``AsyncEngine`` instance for the configured connection string and engine options.
230
+
231
+ :return: The SQLAlchemy ``AsyncEngine``.
232
+ """
233
+ return sqlalchemy.ext.asyncio.create_async_engine(self.connection_string, **self.engine_opts)
234
+
235
+ async def make_connection(self) -> sqlalchemy.ext.asyncio.AsyncConnection:
236
+ """
237
+ Asynchronously establishes and returns a new database connection using the SQLAlchemy async engine.
238
+
239
+ :return: A database connection object.
240
+ """
241
+ return await self.engine.connect()
242
+
243
+ def make_session(self, **kwargs) -> contextlib.AbstractAsyncContextManager[
244
+ sqlalchemy.ext.asyncio.AsyncSession
245
+ ]:
246
+ """
247
+ Creates a context-managed asynchronous SQLAlchemy session with the configured async engine and session options.
248
+
249
+ :param kwargs: Additional keyword arguments for session creation.
250
+ :return: A context manager yielding a SQLAlchemy ``AsyncSession``.
251
+ """
252
+ return contextlib.aclosing(
253
+ sqlalchemy.ext.asyncio.async_sessionmaker(self.engine, **{**self.session_opts, **kwargs})())
254
+
255
+ async def create_model(self, orm_base):
256
+ """
257
+ Asynchronously creates all tables defined in the given ORM base using the current async engine.
258
+
259
+ :param orm_base: The SQLAlchemy ORM base class.
260
+ """
261
+ if packaging.version.parse(sqlalchemy.__version__) >= packaging.version.parse("2"):
262
+ if not isinstance(orm_base, type) or not issubclass(orm_base, sqlalchemy.orm.DeclarativeBase):
263
+ raise TypeError("not a subclass of 'sqlalchemy.orm.DeclarativeBase'")
264
+
265
+ async with self.engine.begin() as conn:
266
+ await conn.run_sync(orm_base.metadata.create_all)
267
+
268
+ async def drop_model(self, orm_base):
269
+ """
270
+ Asynchronously drops all tables defined in the given ORM base using the current async engine.
271
+
272
+ :param orm_base: The SQLAlchemy ORM base class.
273
+ """
274
+ if packaging.version.parse(sqlalchemy.__version__) >= packaging.version.parse("2"):
275
+ if not isinstance(orm_base, type) or not issubclass(orm_base, sqlalchemy.orm.DeclarativeBase):
276
+ raise TypeError("not a subclass of 'sqlalchemy.orm.DeclarativeBase'")
277
+
278
+ async with self.engine.begin() as conn:
279
+ await conn.run_sync(orm_base.metadata.drop_all)
280
+
281
+ async def execute(self, sql: str, **params):
282
+ """
283
+ Executes the given SQL statement with the specified parameters.
284
+
285
+ :param sql: The SQL statement to execute.
286
+ :param params: The parameters dictionary for the SQL statement.
287
+ """
288
+ async with self.make_session() as session:
289
+ await session.execute(sqlalchemy.text(sql), params)
290
+ await session.commit()
291
+
292
+ async def query_all(self, sql: str, **params) -> Sequence[sqlalchemy.Row[tuple[Any, ...]]]:
293
+ """
294
+ Executes the given SQL query with the specified parameters and returns all result tuples.
295
+
296
+ :param sql: The SQL query to execute.
297
+ :param params: The parameters dictionary for the SQL query.
298
+ :return: A list of result tuples.
299
+ """
300
+ async with self.make_session() as session:
301
+ query = await session.execute(sqlalchemy.text(sql), params)
302
+ return query.all()
303
+
304
+ async def query_first(self, sql: str, **params) -> sqlalchemy.Row[tuple[Any, ...]] | None:
305
+ """
306
+ Executes the given SQL query with the specified parameters and returns the first result tuple, or ``None``
307
+ if no results are found.
308
+
309
+ :param sql: The SQL query to execute.
310
+ :param params: The parameters dictionary for the SQL query.
311
+ :return: The first result tuple, or ``None`` if no results are found.
312
+ """
313
+ async with self.make_session() as session:
314
+ query = await session.execute(sqlalchemy.text(sql), params)
315
+ return query.first()
316
+
317
+
217
318
  def orm_to_dict(orm, exclude: set[str] = None) -> dict[str, Any]:
218
319
  """
219
320
  Converts an ORM object to a dictionary, optionally excluding specified fields.
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iker-python-common
3
- Version: 1.0.61
3
+ Version: 1.0.63
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Python :: 3.12
6
6
  Classifier: Programming Language :: Python :: 3.13
7
7
  Classifier: Programming Language :: Python :: 3.14
8
8
  Requires-Python: <3.15,>=3.12
9
- Requires-Dist: docker>=7.1
9
+ Requires-Dist: asyncpg>=0.30
10
10
  Requires-Dist: numpy>=2.3
11
11
  Requires-Dist: psycopg>=3.2
12
12
  Requires-Dist: pymysql>=1.1
@@ -15,6 +15,7 @@ Provides-Extra: all
15
15
  Requires-Dist: iker-python-common; extra == "all"
16
16
  Provides-Extra: test
17
17
  Requires-Dist: ddt>=1.7; extra == "test"
18
+ Requires-Dist: pytest-asyncio>=1.2; extra == "test"
18
19
  Requires-Dist: pytest-cov>=5.0; extra == "test"
19
20
  Requires-Dist: pytest-mysql>=3.0; extra == "test"
20
21
  Requires-Dist: pytest-order>=1.3; extra == "test"
@@ -3,8 +3,7 @@ iker/common/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU
3
3
  iker/common/utils/argutils.py,sha256=hMLNqdZs_Kjc2hw4Npm6N47RivP6JRNzCKIbJr1jYy8,9274
4
4
  iker/common/utils/config.py,sha256=z8rLqli961A-qAV9EaELp-pKuhNUNaq1Btdv-uwG7_I,4690
5
5
  iker/common/utils/csv.py,sha256=_V9OUrKcojec2L-hWagEIVnL2uvGjyJAFTrD7tHNr48,7573
6
- iker/common/utils/dbutils.py,sha256=zXZVJCz7HZPityFRF7sHRRMpMraegV_hyYnzApUUPhY,11852
7
- iker/common/utils/dockerutils.py,sha256=n2WuzXaZB6_WocSljvPOnfExSIjIHRUbuWp2oBbaPKQ,8004
6
+ iker/common/utils/dbutils.py,sha256=09DgvfPVDCPXwOAO_FTynLXhSq--ZzRz2fCQ6vJ5qqk,16151
8
7
  iker/common/utils/dtutils.py,sha256=86vbaa4pgcBWERZvTfJ92PKB3IimxP6tf0O11ho2Ffk,12554
9
8
  iker/common/utils/funcutils.py,sha256=4AkkvK9_Z2tgk1-Sp6-vLLVhI15cIgN9xW58QqL5QL4,7780
10
9
  iker/common/utils/jsonutils.py,sha256=AkziMAYVQDODHRqZC-c1x7VqI2hHY3Kxrw7gmoss8mU,18527
@@ -18,7 +17,7 @@ iker/common/utils/span.py,sha256=u_KuWi2U7QDMUotl4AeW2_57ItL3YhVDSeCwaOiFDvs,596
18
17
  iker/common/utils/strutils.py,sha256=Tu_qFeH3K-SfwvMxdrZAc9iLPV8ZmtX4ntyyFGNslf8,5094
19
18
  iker/common/utils/testutils.py,sha256=2VieV5yeCDntSKQSpIeyqRT8BZmZYE_ArMeQz3g7fXY,5568
20
19
  iker/common/utils/typeutils.py,sha256=RVkYkFRgDrx77OHFH7PavMV0AIB0S8ly40rs4g7JWE4,8220
21
- iker_python_common-1.0.61.dist-info/METADATA,sha256=ljOFhe6VlK3XJSewSNpaYBZEOfQu5TCKVE47pvggop4,813
22
- iker_python_common-1.0.61.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
23
- iker_python_common-1.0.61.dist-info/top_level.txt,sha256=4_B8Prfc_lxFafFYTQThIU1ZqOYQ4pHHHnJ_fQ_oHs8,5
24
- iker_python_common-1.0.61.dist-info/RECORD,,
20
+ iker_python_common-1.0.63.dist-info/METADATA,sha256=QUw6lKzGDZRY3Aul1qTE4B_dxtO96Tw0Mvo_acp9Ilw,867
21
+ iker_python_common-1.0.63.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
22
+ iker_python_common-1.0.63.dist-info/top_level.txt,sha256=4_B8Prfc_lxFafFYTQThIU1ZqOYQ4pHHHnJ_fQ_oHs8,5
23
+ iker_python_common-1.0.63.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,236 +0,0 @@
1
- import contextlib
2
- import dataclasses
3
- import re
4
- from collections.abc import Generator, Iterator
5
- from typing import Any
6
-
7
- import docker
8
- import docker.errors
9
- import docker.models.containers
10
- import docker.models.images
11
- import requests.exceptions
12
-
13
- from iker.common.utils import logger
14
- from iker.common.utils.strutils import parse_int_or, trim_to_empty
15
-
16
- __all__ = [
17
- "ImageName",
18
- "docker_create_client",
19
- "docker_build_image",
20
- "docker_get_image",
21
- "docker_pull_image",
22
- "docker_fetch_image",
23
- "docker_run_detached",
24
- ]
25
-
26
-
27
- @dataclasses.dataclass
28
- class ImageName(object):
29
- registry_host: str | None
30
- registry_port: int | None
31
- components: list[str]
32
- tag: str | None
33
-
34
- @property
35
- def registry(self) -> str:
36
- if self.registry_host is None and self.registry_port is None:
37
- return ""
38
- if self.registry_port is None:
39
- return self.registry_host
40
- return f"{self.registry_host}:{self.registry_port}"
41
-
42
- @property
43
- def repository(self) -> str:
44
- return "/".join(self.components)
45
-
46
- @staticmethod
47
- def parse(s: str):
48
-
49
- # Registry absent version
50
- matcher = re.compile(
51
- r"^(?P<components>[a-z0-9]+((__?|-+)[a-z0-9]+)*(/[a-z0-9]+((__?|-+)[a-z0-9]+)*)*)(:(?P<tag>\w[\w.-]{0,127}))?$")
52
- match = matcher.match(s)
53
- if match:
54
- return ImageName(registry_host=None,
55
- registry_port=None,
56
- components=trim_to_empty(match.group("components")).split("/"),
57
- tag=match.group("tag"))
58
-
59
- # Registry present version
60
- matcher = re.compile(
61
- r"^((?P<host>[a-zA-Z0-9.-]+)(:(?P<port>\d+))?/)?(?P<components>[a-z0-9]+((__?|-+)[a-z0-9]+)*(/[a-z0-9]+((__?|-+)[a-z0-9]+)*)*)(:(?P<tag>\w[\w.-]{0,127}))?$")
62
- match = matcher.match(s)
63
- if match:
64
- return ImageName(registry_host=match.group("host"),
65
- registry_port=parse_int_or(match.group("port"), None),
66
- components=trim_to_empty(match.group("components")).split("/"),
67
- tag=match.group("tag"))
68
-
69
- return None
70
-
71
-
72
- def docker_create_client(
73
- registry: str,
74
- username: str,
75
- password: str,
76
- ) -> contextlib.AbstractContextManager[docker.DockerClient]:
77
- try:
78
- client = docker.DockerClient()
79
- client.login(registry=registry, username=username, password=password, reauth=True)
80
- return contextlib.closing(client)
81
- except docker.errors.APIError:
82
- logger.exception("Failed to login Docker server <%s>", registry)
83
- raise
84
-
85
-
86
- def docker_build_image(
87
- client: docker.DockerClient,
88
- tag: str,
89
- path: str,
90
- dockerfile: str,
91
- build_args: dict[str, str],
92
- ) -> tuple[docker.models.images.Image, Iterator[dict[str, str]]]:
93
- try:
94
- return client.images.build(tag=tag,
95
- path=path,
96
- dockerfile=dockerfile,
97
- buildargs=build_args,
98
- rm=True,
99
- forcerm=True,
100
- nocache=True)
101
-
102
- except docker.errors.BuildError:
103
- logger.exception("Failed to build image <%s>", tag)
104
- raise
105
- except docker.errors.APIError:
106
- logger.exception("Docker server returns an error while building image <%s>", tag)
107
- raise
108
- except Exception:
109
- logger.exception("Unexpected error occurred while building image <%s>", tag)
110
- raise
111
-
112
-
113
- def docker_get_image(
114
- client: docker.DockerClient,
115
- image: str,
116
- ) -> docker.models.images.Image:
117
- try:
118
- return client.images.get(image)
119
- except docker.errors.ImageNotFound:
120
- logger.exception("Image <%s> is not found from local repository", image)
121
- raise
122
- except docker.errors.APIError:
123
- logger.exception("Docker server returns an error while getting image <%s>", image)
124
- raise
125
- except Exception:
126
- logger.exception("Unexpected error occurred while getting image <%s>", image)
127
- raise
128
-
129
-
130
- def docker_pull_image(
131
- client: docker.DockerClient,
132
- image: str,
133
- fallback_local: bool = False,
134
- ) -> docker.models.images.Image:
135
- try:
136
- return client.images.pull(image)
137
- except docker.errors.ImageNotFound:
138
- if not fallback_local:
139
- logger.exception("Image <%s> is not found from remote repository", image)
140
- raise
141
- logger.warning("Image <%s> is not found from remote repository, try local repository instead", image)
142
- except docker.errors.APIError:
143
- if not fallback_local:
144
- logger.exception("Docker server returns an error while pulling image <%s>", image)
145
- raise
146
- logger.warning("Docker server returns an error while pulling image <%s>, try local repository instead", image)
147
- except Exception:
148
- logger.exception("Unexpected error occurred while pulling image <%s>", image)
149
- raise
150
-
151
- return docker_get_image(client, image)
152
-
153
-
154
- def docker_fetch_image(
155
- client: docker.DockerClient,
156
- image: str,
157
- force_pull: bool = False,
158
- ) -> docker.models.images.Image:
159
- if force_pull:
160
- return docker_pull_image(client, image, fallback_local=True)
161
- else:
162
- try:
163
- return docker_get_image(client, image)
164
- except Exception:
165
- return docker_pull_image(client, image, fallback_local=False)
166
-
167
-
168
- def docker_run_detached(
169
- client: docker.DockerClient,
170
- image: str,
171
- name: str,
172
- command: str | list[str],
173
- volumes: dict[str, dict[str, str]],
174
- environment: dict[str, str],
175
- extra_hosts: dict[str, str],
176
- timeout: int,
177
- **kwargs,
178
- ) -> tuple[dict[str, Any], Any]:
179
- @contextlib.contextmanager
180
- def managed_docker_run(
181
- client: docker.DockerClient,
182
- **kwargs,
183
- ) -> Generator[docker.models.containers.Container, None, None]:
184
- container_model = None
185
- try:
186
- container_model = client.containers.run(**kwargs)
187
- yield container_model
188
- except docker.errors.DockerException:
189
- raise
190
- finally:
191
- if container_model is not None:
192
- try:
193
- if container_model.status != "exited":
194
- try:
195
- container_model.stop()
196
- except docker.errors.DockerException:
197
- pass
198
- container_model.wait()
199
- try:
200
- container_model.remove()
201
- except docker.errors.DockerException:
202
- pass
203
- except Exception:
204
- raise
205
-
206
- try:
207
- run_args = kwargs.copy()
208
- run_args.update(dict(image=image,
209
- name=name,
210
- command=command,
211
- volumes=volumes,
212
- environment=environment,
213
- extra_hosts=extra_hosts,
214
- detach=True))
215
-
216
- with managed_docker_run(client, **run_args) as container_model:
217
- result = container_model.wait(timeout=timeout)
218
- logs = container_model.logs()
219
-
220
- return result, logs
221
-
222
- except requests.exceptions.ReadTimeout:
223
- logger.exception("Running container <%s> of image <%s> exceed the timeout", name, image)
224
- raise
225
- except docker.errors.ImageNotFound:
226
- logger.exception("Image <%s> is not found", image)
227
- raise
228
- except docker.errors.ContainerError:
229
- logger.exception("Failed to run container <%s> of image <%s>", name, image)
230
- raise
231
- except docker.errors.APIError:
232
- logger.exception("Docker server returns an error while running container <%s> of image <%s>", name, image)
233
- raise
234
- except Exception:
235
- logger.exception("Unexpected error occurred while running container <%s> of image <%s>", image)
236
- raise