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.
- iker/common/__init__.py +6 -0
- iker/common/core/__init__.py +0 -0
- iker/common/core/exceptions.py +64 -0
- iker/common/utils/__init__.py +0 -0
- iker/common/utils/config.py +117 -0
- iker/common/utils/dbutils.py +203 -0
- iker/common/utils/dockerutils.py +223 -0
- iker/common/utils/dtutils.py +187 -0
- iker/common/utils/funcutils.py +101 -0
- iker/common/utils/logger.py +67 -0
- iker/common/utils/numutils.py +103 -0
- iker/common/utils/randutils.py +147 -0
- iker/common/utils/retry.py +182 -0
- iker/common/utils/s3utils.py +270 -0
- iker/common/utils/sequtils.py +394 -0
- iker/common/utils/shutils.py +229 -0
- iker/common/utils/stream.py +188 -0
- iker/common/utils/strutils.py +159 -0
- iker/common/utils/testutils.py +171 -0
- iker_python_common-1.0.1.dist-info/METADATA +40 -0
- iker_python_common-1.0.1.dist-info/RECORD +23 -0
- iker_python_common-1.0.1.dist-info/WHEEL +5 -0
- iker_python_common-1.0.1.dist-info/top_level.txt +1 -0
iker/common/__init__.py
ADDED
|
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
|