iker-python-common 1.0.1__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,6 @@
1
+ import importlib.metadata
2
+
3
+ try:
4
+ __version__ = importlib.metadata.version("iker-python-common")
5
+ except importlib.metadata.PackageNotFoundError:
6
+ __version__ = "unknown"
File without changes
@@ -0,0 +1,64 @@
1
+ import traceback
2
+
3
+
4
+ class BaseTraceableException(Exception):
5
+ """
6
+ Base class of traceable exceptions and errors
7
+ """
8
+
9
+ def __init__(self, message: str, *args, **kwargs):
10
+ super(BaseTraceableException, self).__init__()
11
+ self.message = message
12
+ self.args = args
13
+ self.cause = kwargs.get("cause")
14
+ self.traceback = "" if kwargs.get("cause") is None else traceback.format_exc()
15
+
16
+ def __str__(self) -> str:
17
+ return self.message % self.args
18
+
19
+ def __traceback(self):
20
+ if self.cause is None or not isinstance(self.cause, BaseTraceableException):
21
+ return self.traceback
22
+ return self.traceback + self.cause.__traceback()
23
+
24
+ @staticmethod
25
+ def format_traceback(error):
26
+ if isinstance(error, BaseTraceableException):
27
+ return traceback.format_exc() + error.__traceback()
28
+ return traceback.format_exc()
29
+
30
+
31
+ class RuntimeException(BaseTraceableException):
32
+ """
33
+ Represents runtime exception
34
+ """
35
+
36
+ def __init__(self, message: str, *args, **kwargs):
37
+ super(RuntimeException, self).__init__(message, *args, **kwargs)
38
+
39
+
40
+ class InvalidTypeException(BaseTraceableException):
41
+ """
42
+ Represents invalid type exception
43
+ """
44
+
45
+ def __init__(self, message: str, *args, **kwargs):
46
+ super(InvalidTypeException, self).__init__(message, *args, **kwargs)
47
+
48
+
49
+ class InvalidKeyException(BaseTraceableException):
50
+ """
51
+ Represents invalid key exception
52
+ """
53
+
54
+ def __init__(self, message: str, *args, **kwargs):
55
+ super(InvalidKeyException, self).__init__(message, *args, **kwargs)
56
+
57
+
58
+ class InvalidValueException(BaseTraceableException):
59
+ """
60
+ Represents invalid value exception
61
+ """
62
+
63
+ def __init__(self, message: str, *args, **kwargs):
64
+ super(InvalidValueException, self).__init__(message, *args, **kwargs)
File without changes
@@ -0,0 +1,117 @@
1
+ import configparser
2
+ from typing import Self
3
+
4
+ from iker.common.utils import logger
5
+ from iker.common.utils.strutils import is_blank, trim_to_empty
6
+
7
+
8
+ class Config(object):
9
+ def __init__(self, config_path: str = None):
10
+ self.config_path = trim_to_empty(config_path)
11
+ self.config_parser: configparser.RawConfigParser = configparser.RawConfigParser(strict=False)
12
+
13
+ def __len__(self):
14
+ return sum(len(self.config_parser.options(section)) for section in self.config_parser.sections())
15
+
16
+ def update(self, tuples: list[tuple[str, str, str]], *, overwrite: bool = False):
17
+ for section, option, value in tuples:
18
+ if not self.config_parser.has_section(section):
19
+ self.config_parser.add_section(section)
20
+ if overwrite or not self.config_parser.has_option(section, option):
21
+ self.config_parser.set(section, option, value)
22
+
23
+ def restore(self) -> bool:
24
+ self.config_parser = configparser.RawConfigParser(strict=False)
25
+ if is_blank(self.config_path):
26
+ return False
27
+ try:
28
+ self.config_parser.read(self.config_path)
29
+ return True
30
+ except IOError as e:
31
+ logger.exception("Failed to restore config from file <%s>", self.config_path)
32
+ return False
33
+
34
+ def persist(self) -> bool:
35
+ if is_blank(self.config_path):
36
+ return False
37
+ try:
38
+ with open(self.config_path, "w") as fh:
39
+ self.config_parser.write(fh)
40
+ return True
41
+ except IOError as e:
42
+ logger.exception("Failed to persist config to file <%s>", self.config_path)
43
+ return False
44
+
45
+ def has_section(self, section: str) -> bool:
46
+ return self.config_parser.has_section(section)
47
+
48
+ def has(self, section: str, option: str) -> bool:
49
+ return self.config_parser.has_option(section, option)
50
+
51
+ def get(self, section: str, option: str, default_value: str = None) -> str:
52
+ if self.config_parser.has_option(section, option):
53
+ return self.config_parser.get(section, option)
54
+ return default_value
55
+
56
+ def getint(self, section: str, option: str, default_value: int = None) -> int:
57
+ if self.config_parser.has_option(section, option):
58
+ return self.config_parser.getint(section, option)
59
+ return default_value
60
+
61
+ def getfloat(self, section: str, option: str, default_value: float = None) -> float:
62
+ if self.config_parser.has_option(section, option):
63
+ return self.config_parser.getfloat(section, option)
64
+ return default_value
65
+
66
+ def getboolean(self, section: str, option: str, default_value: bool = None) -> bool:
67
+ if self.config_parser.has_option(section, option):
68
+ return self.config_parser.getboolean(section, option)
69
+ return default_value
70
+
71
+ def set(self, section: str, option: str, value: str):
72
+ if not self.config_parser.has_section(section):
73
+ self.config_parser.add_section(section)
74
+ self.config_parser.set(section, option, value)
75
+
76
+ def sections(self) -> list[str]:
77
+ return self.config_parser.sections()
78
+
79
+ def options(self, section: str) -> list[str]:
80
+ if not self.config_parser.has_section(section):
81
+ return []
82
+ return self.config_parser.options(section)
83
+
84
+ def tuples(self) -> list[tuple[str, str, str]]:
85
+ result = []
86
+ for section in self.config_parser.sections():
87
+ for option in self.config_parser.options(section):
88
+ value = self.config_parser.get(section, option)
89
+ result.append((section, option, value))
90
+ return result
91
+
92
+
93
+ class ConfigVisitor(object):
94
+ def __init__(self, config: Config, section: str, prefix: str = "", separator: str = "."):
95
+ self.config = config
96
+ self.section = section
97
+ self.prefix = prefix
98
+ self.separator = separator
99
+
100
+ def __str__(self):
101
+ return self.config.get(self.section, self.prefix)
102
+
103
+ def __int__(self):
104
+ return self.config.getint(self.section, self.prefix)
105
+
106
+ def __float__(self):
107
+ return self.config.getfloat(self.section, self.prefix)
108
+
109
+ def __bool__(self):
110
+ return self.config.getboolean(self.section, self.prefix)
111
+
112
+ def __getattr__(self, suffix: str) -> Self:
113
+ return self[suffix]
114
+
115
+ def __getitem__(self, suffix: str) -> Self:
116
+ new_prefix = suffix if is_blank(self.prefix) else self.separator.join([self.prefix, suffix])
117
+ return ConfigVisitor(self.config, self.section, new_prefix, self.separator)
@@ -0,0 +1,203 @@
1
+ import contextlib
2
+ import dataclasses
3
+ import datetime
4
+ import urllib.parse
5
+ from typing import Any, ContextManager, Type
6
+
7
+ import psycopg2
8
+ import psycopg2.extensions
9
+ import sqlalchemy
10
+ import sqlalchemy.ext.compiler
11
+ import sqlalchemy.orm
12
+
13
+ from iker.common.utils.funcutils import singleton
14
+ from iker.common.utils.sequtils import head_or_none
15
+ from iker.common.utils.strutils import is_blank
16
+
17
+ __all__ = [
18
+ "DBAdapter",
19
+ "orm_to_dict",
20
+ "orm_clone",
21
+ "to_pg_date",
22
+ "to_pg_time",
23
+ "to_pg_ts",
24
+ "to_pg_ts",
25
+ "pg_date_max",
26
+ "pg_ts_max",
27
+ "mysql_insert_ignore",
28
+ "postgresql_insert_on_conflict_do_nothing",
29
+ ]
30
+
31
+
32
+ class DBAdapter(object):
33
+ """
34
+ Database adapter
35
+ """
36
+
37
+ class Drivers:
38
+ Mysql = "mysql+mysqldb"
39
+ Postgresql = "postgresql+psycopg2"
40
+
41
+ def __init__(
42
+ self,
43
+ driver: str,
44
+ host: str,
45
+ port: int,
46
+ user: str,
47
+ password: str,
48
+ database: str,
49
+ engine_opts: dict[str, Any] | None = None,
50
+ session_opts: dict[str, Any] | None = None,
51
+ ):
52
+ self.driver = driver
53
+ self.host = host
54
+ self.port = port
55
+ self.user = user
56
+ self.password = password
57
+ self.database = database
58
+ self.engine_opts = engine_opts or {}
59
+ self.session_opts = session_opts or {}
60
+
61
+ @property
62
+ def connection_string(self) -> str:
63
+ port_part = "" if self.port is None else (":%d" % self.port)
64
+ user_part = urllib.parse.quote(self.user, safe="")
65
+ password_part = "" if is_blank(self.password) else (":%s" % urllib.parse.quote(self.password, safe=""))
66
+ database_part = urllib.parse.quote(self.database, safe="")
67
+
68
+ return f"{self.driver}://{user_part}{password_part}@{self.host}{port_part}/{database_part}"
69
+
70
+ @property
71
+ def engine(self) -> sqlalchemy.Engine:
72
+ return sqlalchemy.create_engine(self.connection_string, **self.engine_opts)
73
+
74
+ def make_connection(self):
75
+ return self.engine.connect()
76
+
77
+ def make_session(self, **kwargs) -> ContextManager[sqlalchemy.orm.Session]:
78
+ return contextlib.closing(sqlalchemy.orm.sessionmaker(self.engine, **{**self.session_opts, **kwargs})())
79
+
80
+ def create_model(self, orm_base: Type[sqlalchemy.orm.DeclarativeBase]):
81
+ orm_base.metadata.create_all(self.engine)
82
+
83
+ def drop_model(self, orm_base: Type[sqlalchemy.orm.DeclarativeBase]):
84
+ orm_base.metadata.drop_all(self.engine)
85
+
86
+ def execute(self, sql: str, **params):
87
+ """
88
+ Executes the given SQL statement with the specific parameters
89
+
90
+ :param sql: SQL statement to execute
91
+ :param params: parameters dict
92
+ """
93
+ with self.make_connection() as connection:
94
+ connection.execute(sqlalchemy.text(sql), params)
95
+ connection.commit()
96
+
97
+ def query_all(self, sql: str, **params) -> list[tuple]:
98
+ """
99
+ Executes the given SQL query with the specific parameters and returns all the results
100
+
101
+ :param sql: SQL query to execute
102
+ :param params: parameters dict
103
+ :return: result tuples list
104
+ """
105
+ with self.make_connection() as connection:
106
+ with contextlib.closing(connection.execute(sqlalchemy.text(sql), params)) as proxy:
107
+ return [item for item in proxy.fetchall()]
108
+
109
+ def query_first(self, sql: str, **params) -> tuple | None:
110
+ """
111
+ Executes the given SQL query with the specific parameters and returns the first result tuple
112
+
113
+ :param sql: SQL query to execute
114
+ :param params: parameters dict
115
+ :return: the first result tuple
116
+ """
117
+ return head_or_none(self.query_all(sql, **params))
118
+
119
+
120
+ def orm_to_dict(orm: sqlalchemy.orm.DeclarativeBase, exclude: set[str] = None) -> dict[str, Any]:
121
+ if not isinstance(orm, sqlalchemy.orm.DeclarativeBase):
122
+ raise TypeError('not a SQLAlchemy ORM declarative base')
123
+
124
+ mapper = sqlalchemy.inspect(type(orm))
125
+ return dict((c.key, getattr(orm, c.key)) for c in mapper.columns if c.key not in (exclude or set()))
126
+
127
+
128
+ def orm_clone(orm: sqlalchemy.orm.DeclarativeBase, exclude: set[str] = None, no_autoincrement: bool = False):
129
+ if not isinstance(orm, sqlalchemy.orm.DeclarativeBase):
130
+ raise TypeError('not a SQLAlchemy ORM declarative base')
131
+
132
+ mapper = sqlalchemy.inspect(type(orm))
133
+ exclude = exclude or (set(c.key for c in mapper.columns if c.autoincrement is True) if no_autoincrement else set())
134
+ fields = orm_to_dict(orm, exclude)
135
+
136
+ if not dataclasses.is_dataclass(orm):
137
+ return type(orm)(**fields)
138
+
139
+ init_fields = dict((field.name, fields.get(field.name)) for field in dataclasses.fields(orm) if field.init)
140
+
141
+ new_orm = type(orm)(**init_fields)
142
+ for name, value in fields.items():
143
+ if name not in init_fields:
144
+ setattr(new_orm, name, value)
145
+ return new_orm
146
+
147
+
148
+ def to_pg_date(dt: datetime.datetime | datetime.date | int | float):
149
+ if isinstance(dt, (datetime.datetime, datetime.date)):
150
+ return psycopg2.extensions.DateFromPy(dt)
151
+ elif isinstance(dt, (int, float)):
152
+ return psycopg2.DateFromTicks(dt)
153
+ raise TypeError("should be one of 'datetime.datetime', 'datetime.date', 'int', 'float'")
154
+
155
+
156
+ def to_pg_time(dt: datetime.time | int | float):
157
+ if isinstance(dt, datetime.time):
158
+ return psycopg2.extensions.TimeFromPy(dt)
159
+ elif isinstance(dt, (int, float)):
160
+ return psycopg2.TimeFromTicks(dt)
161
+ raise TypeError("should be one of 'datetime.time', 'int', 'float'")
162
+
163
+
164
+ def to_pg_ts(dt: datetime.datetime | datetime.date | int | float):
165
+ if isinstance(dt, (datetime.datetime, datetime.date)):
166
+ return psycopg2.extensions.TimestampFromPy(dt)
167
+ elif isinstance(dt, (int, float)):
168
+ return psycopg2.TimestampFromTicks(dt)
169
+ raise TypeError("should be one of 'datetime.datetime', 'datetime.date', 'int', 'float'")
170
+
171
+
172
+ @singleton
173
+ def pg_date_max():
174
+ return psycopg2.Date(9999, 12, 31)
175
+
176
+
177
+ @singleton
178
+ def pg_ts_max():
179
+ return psycopg2.Timestamp(9999, 12, 31, 23, 59, 59.999, tzinfo=datetime.timezone.utc)
180
+
181
+
182
+ def mysql_insert_ignore(enabled: bool = True):
183
+ @sqlalchemy.ext.compiler.compiles(sqlalchemy.sql.Insert, "mysql")
184
+ def dispatch(insert: sqlalchemy.sql.Insert, compiler: sqlalchemy.sql.compiler.SQLCompiler, **kwargs) -> str:
185
+ if not enabled:
186
+ return compiler.visit_insert(insert, **kwargs)
187
+
188
+ return compiler.visit_insert(insert.prefix_with("IGNORE"), **kwargs)
189
+
190
+
191
+ def postgresql_insert_on_conflict_do_nothing(enabled: bool = True):
192
+ @sqlalchemy.ext.compiler.compiles(sqlalchemy.sql.Insert, "postgresql")
193
+ def dispatch(insert: sqlalchemy.sql.Insert, compiler: sqlalchemy.sql.compiler.SQLCompiler, **kwargs) -> str:
194
+ if not enabled:
195
+ return compiler.visit_insert(insert, **kwargs)
196
+
197
+ statement = compiler.visit_insert(insert, **kwargs)
198
+ # If we have a "RETURNING" clause, we must insert before it
199
+ returning_position = statement.find("RETURNING")
200
+ if returning_position >= 0:
201
+ return statement[:returning_position] + " ON CONFLICT DO NOTHING " + statement[returning_position:]
202
+ else:
203
+ return statement + " ON CONFLICT DO NOTHING"
@@ -0,0 +1,223 @@
1
+ import contextlib
2
+ import re
3
+ from dataclasses import dataclass
4
+ from typing import Any, ContextManager, Iterable
5
+
6
+ import docker
7
+ import docker.errors
8
+ import docker.models.containers
9
+ import docker.models.images
10
+ import requests.exceptions
11
+
12
+ from iker.common.utils import logger
13
+ from iker.common.utils.strutils import parse_int_or, trim_to_empty
14
+
15
+ __all__ = [
16
+ "ImageName",
17
+ "docker_create_client",
18
+ "docker_build_image",
19
+ "docker_get_image",
20
+ "docker_pull_image",
21
+ "docker_fetch_image",
22
+ "docker_run_detached",
23
+ ]
24
+
25
+
26
+ @dataclass
27
+ class ImageName(object):
28
+ registry_host: str | None
29
+ registry_port: int | None
30
+ components: list[str]
31
+ tag: str | None
32
+
33
+ @property
34
+ def registry(self) -> str:
35
+ if self.registry_host is None and self.registry_port is None:
36
+ return ""
37
+ if self.registry_port is None:
38
+ return self.registry_host
39
+ return f"{self.registry_host}:{self.registry_port}"
40
+
41
+ @property
42
+ def repository(self) -> str:
43
+ return "/".join(self.components)
44
+
45
+ @staticmethod
46
+ def parse(s: str):
47
+
48
+ # Registry absent version
49
+ matcher = re.compile(r"^(?P<components>[\w-]+(/[\w-]+)*)(:(?P<tag>[\w._-]+))?$")
50
+ match = matcher.match(s)
51
+ if match:
52
+ return ImageName(registry_host=None,
53
+ registry_port=None,
54
+ components=trim_to_empty(match.group("components")).split("/"),
55
+ tag=match.group("tag"))
56
+
57
+ # Registry present version
58
+ matcher = re.compile(
59
+ r"^(?:(?P<host>[\w.-]+)(?::(?P<port>\d+))?/)?(?P<components>[\w-]+(/[\w-]+)*)(:(?P<tag>[\w._-]+))?$")
60
+ match = matcher.match(s)
61
+ if match:
62
+ return ImageName(registry_host=match.group("host"),
63
+ registry_port=parse_int_or(match.group("port"), None),
64
+ components=trim_to_empty(match.group("components")).split("/"),
65
+ tag=match.group("tag"))
66
+
67
+ return None
68
+
69
+
70
+ def docker_create_client(registry: str, username: str, password: str) -> ContextManager[docker.DockerClient]:
71
+ try:
72
+ client = docker.DockerClient()
73
+ client.login(registry=registry, username=username, password=password, reauth=True)
74
+ return contextlib.closing(client)
75
+ except docker.errors.APIError:
76
+ logger.exception("Failed to login Docker server <%s>", registry)
77
+ raise
78
+
79
+
80
+ def docker_build_image(
81
+ client: docker.DockerClient,
82
+ tag: str,
83
+ path: str,
84
+ dockerfile: str,
85
+ build_args: dict[str, str],
86
+ ) -> tuple[docker.models.images.Image, Iterable[Any]]:
87
+ try:
88
+ return client.images.build(tag=tag,
89
+ path=path,
90
+ dockerfile=dockerfile,
91
+ buildargs=build_args,
92
+ rm=True,
93
+ forcerm=True,
94
+ nocache=True)
95
+
96
+ except docker.errors.BuildError:
97
+ logger.exception("Failed to build image <%s>", tag)
98
+ raise
99
+ except docker.errors.APIError:
100
+ logger.exception("Docker server returns an error while building image <%s>", tag)
101
+ raise
102
+ except Exception:
103
+ logger.exception("Unexpected error occurred while building image <%s>", tag)
104
+ raise
105
+
106
+
107
+ def docker_get_image(
108
+ client: docker.DockerClient,
109
+ image: str,
110
+ ) -> docker.models.images.Image:
111
+ try:
112
+ return client.images.get(image)
113
+ except docker.errors.ImageNotFound:
114
+ logger.exception("Image <%s> is not found from local repository", image)
115
+ raise
116
+ except docker.errors.APIError:
117
+ logger.exception("Docker server returns an error while getting image <%s>", image)
118
+ raise
119
+ except Exception:
120
+ logger.exception("Unexpected error occurred while getting image <%s>", image)
121
+ raise
122
+
123
+
124
+ def docker_pull_image(
125
+ client: docker.DockerClient,
126
+ image: str,
127
+ fallback_local: bool = False,
128
+ ) -> docker.models.images.Image:
129
+ try:
130
+ return client.images.pull(image)
131
+ except docker.errors.ImageNotFound:
132
+ if not fallback_local:
133
+ logger.exception("Image <%s> is not found from remote repository", image)
134
+ raise
135
+ logger.warning("Image <%s> is not found from remote repository, try local repository instead", image)
136
+ except docker.errors.APIError:
137
+ if not fallback_local:
138
+ logger.exception("Docker server returns an error while pulling image <%s>", image)
139
+ raise
140
+ logger.warning("Docker server returns an error while pulling image <%s>, try local repository instead", image)
141
+ except Exception:
142
+ logger.exception("Unexpected error occurred while pulling image <%s>", image)
143
+ raise
144
+
145
+ return docker_get_image(client, image)
146
+
147
+
148
+ def docker_fetch_image(
149
+ client: docker.DockerClient,
150
+ image: str,
151
+ force_pull: bool = False,
152
+ ) -> docker.models.images.Image:
153
+ if force_pull:
154
+ return docker_pull_image(client, image, fallback_local=True)
155
+ else:
156
+ try:
157
+ return docker_get_image(client, image)
158
+ except Exception:
159
+ return docker_pull_image(client, image, fallback_local=False)
160
+
161
+
162
+ def docker_run_detached(
163
+ client: docker.DockerClient,
164
+ image: str,
165
+ name: str,
166
+ command: str | list[str],
167
+ volumes: dict[str, dict[str, str]],
168
+ environment: dict[str, str],
169
+ extra_hosts: dict[str, str],
170
+ timeout: int,
171
+ **kwargs,
172
+ ) -> tuple[dict[str, Any], Any]:
173
+ @contextlib.contextmanager
174
+ def managed_docker_run(client: docker.DockerClient, **kwargs) -> docker.models.containers.Container:
175
+ container_model = client.containers.run(**kwargs)
176
+ try:
177
+ yield container_model
178
+ finally:
179
+ if container_model.status != "exited":
180
+ try:
181
+ container_model.stop()
182
+ except docker.errors.DockerException:
183
+ pass
184
+ try:
185
+ container_model.wait()
186
+ except docker.errors.DockerException:
187
+ pass
188
+ try:
189
+ container_model.remove()
190
+ except docker.errors.DockerException:
191
+ pass
192
+
193
+ try:
194
+ run_args = kwargs.copy()
195
+ run_args.update(dict(image=image,
196
+ name=name,
197
+ command=command,
198
+ volumes=volumes,
199
+ environment=environment,
200
+ extra_hosts=extra_hosts,
201
+ detach=True))
202
+
203
+ with managed_docker_run(client, **run_args) as container_model:
204
+ result = container_model.wait(timeout=timeout)
205
+ logs = container_model.logs()
206
+
207
+ return result, logs
208
+
209
+ except requests.exceptions.ReadTimeout:
210
+ logger.exception("Running container <%s> of image <%s> exceed the timeout", name, image)
211
+ raise
212
+ except docker.errors.ImageNotFound:
213
+ logger.exception("Image <%s> is not found", image)
214
+ raise
215
+ except docker.errors.ContainerError:
216
+ logger.exception("Failed to run container <%s> of image <%s>", name, image)
217
+ raise
218
+ except docker.errors.APIError:
219
+ logger.exception("Docker server returns an error while running container <%s> of image <%s>", name, image)
220
+ raise
221
+ except Exception:
222
+ logger.exception("Unexpected error occurred while running container <%s> of image <%s>", image)
223
+ raise