omdev 0.0.0.dev210__py3-none-any.whl → 0.0.0.dev211__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.
omdev/ci/cache.py CHANGED
@@ -2,21 +2,24 @@
2
2
  # @omlish-lite
3
3
  import abc
4
4
  import os.path
5
+ import shlex
5
6
  import shutil
6
7
  import typing as ta
7
8
 
9
+ from .shell import ShellCmd
8
10
 
9
- #
11
+
12
+ ##
10
13
 
11
14
 
12
15
  @abc.abstractmethod
13
16
  class FileCache(abc.ABC):
14
17
  @abc.abstractmethod
15
- def get_file(self, name: str) -> ta.Optional[str]:
18
+ def get_file(self, key: str) -> ta.Optional[str]:
16
19
  raise NotImplementedError
17
20
 
18
21
  @abc.abstractmethod
19
- def put_file(self, name: str) -> ta.Optional[str]:
22
+ def put_file(self, key: str, file_path: str) -> ta.Optional[str]:
20
23
  raise NotImplementedError
21
24
 
22
25
 
@@ -29,13 +32,137 @@ class DirectoryFileCache(FileCache):
29
32
 
30
33
  self._dir = dir
31
34
 
32
- def get_file(self, name: str) -> ta.Optional[str]:
33
- file_path = os.path.join(self._dir, name)
34
- if not os.path.exists(file_path):
35
+ #
36
+
37
+ def get_cache_file_path(
38
+ self,
39
+ key: str,
40
+ *,
41
+ make_dirs: bool = False,
42
+ ) -> str:
43
+ if make_dirs:
44
+ os.makedirs(self._dir, exist_ok=True)
45
+ return os.path.join(self._dir, key)
46
+
47
+ def format_incomplete_file(self, f: str) -> str:
48
+ return os.path.join(os.path.dirname(f), f'_{os.path.basename(f)}.incomplete')
49
+
50
+ #
51
+
52
+ def get_file(self, key: str) -> ta.Optional[str]:
53
+ cache_file_path = self.get_cache_file_path(key)
54
+ if not os.path.exists(cache_file_path):
35
55
  return None
36
- return file_path
56
+ return cache_file_path
37
57
 
38
- def put_file(self, file_path: str) -> None:
39
- os.makedirs(self._dir, exist_ok=True)
40
- cache_file_path = os.path.join(self._dir, os.path.basename(file_path))
58
+ def put_file(self, key: str, file_path: str) -> None:
59
+ cache_file_path = self.get_cache_file_path(key, make_dirs=True)
41
60
  shutil.copyfile(file_path, cache_file_path)
61
+
62
+
63
+ ##
64
+
65
+
66
+ class ShellCache(abc.ABC):
67
+ @abc.abstractmethod
68
+ def get_file_cmd(self, key: str) -> ta.Optional[ShellCmd]:
69
+ raise NotImplementedError
70
+
71
+ class PutFileCmdContext(abc.ABC):
72
+ def __init__(self) -> None:
73
+ super().__init__()
74
+
75
+ self._state: ta.Literal['open', 'committed', 'aborted'] = 'open'
76
+
77
+ @property
78
+ def state(self) -> ta.Literal['open', 'committed', 'aborted']:
79
+ return self._state
80
+
81
+ #
82
+
83
+ @property
84
+ @abc.abstractmethod
85
+ def cmd(self) -> ShellCmd:
86
+ raise NotImplementedError
87
+
88
+ #
89
+
90
+ def __enter__(self):
91
+ return self
92
+
93
+ def __exit__(self, exc_type, exc_val, exc_tb):
94
+ if exc_val is None:
95
+ self.commit()
96
+ else:
97
+ self.abort()
98
+
99
+ #
100
+
101
+ @abc.abstractmethod
102
+ def _commit(self) -> None:
103
+ raise NotImplementedError
104
+
105
+ def commit(self) -> None:
106
+ if self._state == 'committed':
107
+ return
108
+ elif self._state == 'open':
109
+ self._commit()
110
+ self._state = 'committed'
111
+ else:
112
+ raise RuntimeError(self._state)
113
+
114
+ #
115
+
116
+ @abc.abstractmethod
117
+ def _abort(self) -> None:
118
+ raise NotImplementedError
119
+
120
+ def abort(self) -> None:
121
+ if self._state == 'aborted':
122
+ return
123
+ elif self._state == 'open':
124
+ self._abort()
125
+ self._state = 'committed'
126
+ else:
127
+ raise RuntimeError(self._state)
128
+
129
+ @abc.abstractmethod
130
+ def put_file_cmd(self, key: str) -> PutFileCmdContext:
131
+ raise NotImplementedError
132
+
133
+
134
+ #
135
+
136
+
137
+ class DirectoryShellCache(ShellCache):
138
+ def __init__(self, dfc: DirectoryFileCache) -> None:
139
+ super().__init__()
140
+
141
+ self._dfc = dfc
142
+
143
+ def get_file_cmd(self, key: str) -> ta.Optional[ShellCmd]:
144
+ f = self._dfc.get_file(key)
145
+ if f is None:
146
+ return None
147
+ return ShellCmd(f'cat {shlex.quote(f)}')
148
+
149
+ class _PutFileCmdContext(ShellCache.PutFileCmdContext): # noqa
150
+ def __init__(self, tf: str, f: str) -> None:
151
+ super().__init__()
152
+
153
+ self._tf = tf
154
+ self._f = f
155
+
156
+ @property
157
+ def cmd(self) -> ShellCmd:
158
+ return ShellCmd(f'cat > {shlex.quote(self._tf)}')
159
+
160
+ def _commit(self) -> None:
161
+ os.replace(self._tf, self._f)
162
+
163
+ def _abort(self) -> None:
164
+ os.unlink(self._tf)
165
+
166
+ def put_file_cmd(self, key: str) -> ShellCache.PutFileCmdContext:
167
+ f = self._dfc.get_cache_file_path(key, make_dirs=True)
168
+ return self._PutFileCmdContext(self._dfc.format_incomplete_file(f), f)
omdev/ci/ci.py CHANGED
@@ -13,19 +13,19 @@ from omlish.lite.contextmanagers import ExitStacked
13
13
  from omlish.lite.contextmanagers import defer
14
14
 
15
15
  from .cache import FileCache
16
+ from .cache import ShellCache
16
17
  from .compose import DockerComposeRun
17
18
  from .compose import get_compose_service_dependencies
18
- from .dockertars import build_docker_file_hash
19
- from .dockertars import build_docker_tar
20
- from .dockertars import is_docker_image_present
21
- from .dockertars import load_docker_tar
22
- from .dockertars import pull_docker_tar
23
- from .dockertars import read_docker_tar_image_id
19
+ from .docker import build_docker_file_hash
20
+ from .docker import build_docker_image
21
+ from .docker import is_docker_image_present
22
+ from .docker import load_docker_tar_cmd
23
+ from .docker import pull_docker_image
24
+ from .docker import save_docker_tar_cmd
24
25
  from .requirements import build_requirements_hash
25
26
  from .requirements import download_requirements
26
-
27
-
28
- ##
27
+ from .shell import ShellCmd
28
+ from .utils import log_timing_context
29
29
 
30
30
 
31
31
  class Ci(ExitStacked):
@@ -40,8 +40,12 @@ class Ci(ExitStacked):
40
40
  compose_file: str
41
41
  service: str
42
42
 
43
+ cmd: ShellCmd
44
+
43
45
  requirements_txts: ta.Optional[ta.Sequence[str]] = None
44
46
 
47
+ always_pull: bool = False
48
+
45
49
  def __post_init__(self) -> None:
46
50
  check.not_isinstance(self.requirements_txts, str)
47
51
 
@@ -49,40 +53,61 @@ class Ci(ExitStacked):
49
53
  self,
50
54
  cfg: Config,
51
55
  *,
56
+ shell_cache: ta.Optional[ShellCache] = None,
52
57
  file_cache: ta.Optional[FileCache] = None,
53
58
  ) -> None:
54
59
  super().__init__()
55
60
 
56
61
  self._cfg = cfg
62
+ self._shell_cache = shell_cache
57
63
  self._file_cache = file_cache
58
64
 
59
65
  #
60
66
 
61
- def load_docker_image(self, image: str) -> None:
62
- if is_docker_image_present(image):
67
+ def _load_cache_docker_image(self, key: str) -> ta.Optional[str]:
68
+ if self._shell_cache is None:
69
+ return None
70
+
71
+ get_cache_cmd = self._shell_cache.get_file_cmd(key)
72
+ if get_cache_cmd is None:
73
+ return None
74
+
75
+ get_cache_cmd = dc.replace(get_cache_cmd, s=f'{get_cache_cmd.s} | zstd -cd --long') # noqa
76
+
77
+ return load_docker_tar_cmd(get_cache_cmd)
78
+
79
+ def _save_cache_docker_image(self, key: str, image: str) -> None:
80
+ if self._shell_cache is None:
81
+ return
82
+
83
+ with self._shell_cache.put_file_cmd(key) as put_cache:
84
+ put_cache_cmd = put_cache.cmd
85
+
86
+ put_cache_cmd = dc.replace(put_cache_cmd, s=f'zstd | {put_cache_cmd.s}')
87
+
88
+ save_docker_tar_cmd(image, put_cache_cmd)
89
+
90
+ #
91
+
92
+ def _load_docker_image(self, image: str) -> None:
93
+ if not self._cfg.always_pull and is_docker_image_present(image):
63
94
  return
64
95
 
65
96
  dep_suffix = image
66
97
  for c in '/:.-_':
67
98
  dep_suffix = dep_suffix.replace(c, '-')
68
99
 
69
- tar_file_name = f'docker-{dep_suffix}.tar'
70
-
71
- if self._file_cache is not None and (cache_tar_file := self._file_cache.get_file(tar_file_name)):
72
- load_docker_tar(cache_tar_file)
100
+ cache_key = f'docker-{dep_suffix}'
101
+ if self._load_cache_docker_image(cache_key) is not None:
73
102
  return
74
103
 
75
- temp_dir = tempfile.mkdtemp()
76
- with defer(lambda: shutil.rmtree(temp_dir)):
77
- temp_tar_file = os.path.join(temp_dir, tar_file_name)
104
+ pull_docker_image(image)
78
105
 
79
- pull_docker_tar(
80
- image,
81
- temp_tar_file,
82
- )
106
+ self._save_cache_docker_image(cache_key, image)
83
107
 
84
- if self._file_cache is not None:
85
- self._file_cache.put_file(temp_tar_file)
108
+ def load_docker_image(self, image: str) -> None:
109
+ with log_timing_context(f'Load docker image: {image}'):
110
+ self._load_docker_image(image)
86
111
 
87
112
  @cached_nullary
88
113
  def load_compose_service_dependencies(self) -> None:
@@ -96,46 +121,46 @@ class Ci(ExitStacked):
96
121
 
97
122
  #
98
123
 
99
- @cached_nullary
100
- def build_ci_image(self) -> str:
124
+ def _resolve_ci_image(self) -> str:
101
125
  docker_file_hash = build_docker_file_hash(self._cfg.docker_file)[:self.FILE_NAME_HASH_LEN]
102
126
 
103
- tar_file_name = f'ci-{docker_file_hash}.tar'
104
-
105
- if self._file_cache is not None and (cache_tar_file := self._file_cache.get_file(tar_file_name)):
106
- image_id = read_docker_tar_image_id(cache_tar_file)
107
- load_docker_tar(cache_tar_file)
108
- return image_id
127
+ cache_key = f'ci-{docker_file_hash}'
128
+ if (cache_image_id := self._load_cache_docker_image(cache_key)) is not None:
129
+ return cache_image_id
109
130
 
110
- temp_dir = tempfile.mkdtemp()
111
- with defer(lambda: shutil.rmtree(temp_dir)):
112
- temp_tar_file = os.path.join(temp_dir, tar_file_name)
131
+ image_id = build_docker_image(
132
+ self._cfg.docker_file,
133
+ cwd=self._cfg.project_dir,
134
+ )
113
135
 
114
- image_id = build_docker_tar(
115
- self._cfg.docker_file,
116
- temp_tar_file,
117
- cwd=self._cfg.project_dir,
118
- )
136
+ self._save_cache_docker_image(cache_key, image_id)
119
137
 
120
- if self._file_cache is not None:
121
- self._file_cache.put_file(temp_tar_file)
138
+ return image_id
122
139
 
140
+ @cached_nullary
141
+ def resolve_ci_image(self) -> str:
142
+ with log_timing_context('Resolve ci image') as ltc:
143
+ image_id = self._resolve_ci_image()
144
+ ltc.set_description(f'Resolve ci image: {image_id}')
123
145
  return image_id
124
146
 
125
147
  #
126
148
 
127
- @cached_nullary
128
- def build_requirements_dir(self) -> str:
129
- requirements_txts = check.not_none(self._cfg.requirements_txts)
149
+ def _resolve_requirements_dir(self) -> str:
150
+ requirements_txts = [
151
+ os.path.join(self._cfg.project_dir, rf)
152
+ for rf in check.not_none(self._cfg.requirements_txts)
153
+ ]
130
154
 
131
155
  requirements_hash = build_requirements_hash(requirements_txts)[:self.FILE_NAME_HASH_LEN]
132
156
 
133
- tar_file_name = f'requirements-{requirements_hash}.tar'
157
+ tar_file_key = f'requirements-{requirements_hash}'
158
+ tar_file_name = f'{tar_file_key}.tar'
134
159
 
135
160
  temp_dir = tempfile.mkdtemp()
136
161
  self._enter_context(defer(lambda: shutil.rmtree(temp_dir))) # noqa
137
162
 
138
- if self._file_cache is not None and (cache_tar_file := self._file_cache.get_file(tar_file_name)):
163
+ if self._file_cache is not None and (cache_tar_file := self._file_cache.get_file(tar_file_key)):
139
164
  with tarfile.open(cache_tar_file) as tar:
140
165
  tar.extractall(path=temp_dir) # noqa
141
166
 
@@ -145,7 +170,7 @@ class Ci(ExitStacked):
145
170
  os.makedirs(temp_requirements_dir)
146
171
 
147
172
  download_requirements(
148
- self.build_ci_image(),
173
+ self.resolve_ci_image(),
149
174
  temp_requirements_dir,
150
175
  requirements_txts,
151
176
  )
@@ -160,21 +185,20 @@ class Ci(ExitStacked):
160
185
  arcname=requirement_file,
161
186
  )
162
187
 
163
- self._file_cache.put_file(temp_tar_file)
188
+ self._file_cache.put_file(os.path.basename(tar_file_key), temp_tar_file)
164
189
 
165
190
  return temp_requirements_dir
166
191
 
167
- #
168
-
169
- def run(self) -> None:
170
- self.load_compose_service_dependencies()
171
-
172
- ci_image = self.build_ci_image()
173
-
174
- requirements_dir = self.build_requirements_dir()
192
+ @cached_nullary
193
+ def resolve_requirements_dir(self) -> str:
194
+ with log_timing_context('Resolve requirements dir') as ltc:
195
+ requirements_dir = self._resolve_requirements_dir()
196
+ ltc.set_description(f'Resolve requirements dir: {requirements_dir}')
197
+ return requirements_dir
175
198
 
176
- #
199
+ #
177
200
 
201
+ def _run_compose_(self) -> None:
178
202
  setup_cmds = [
179
203
  'pip install --root-user-action ignore --find-links /requirements --no-index uv',
180
204
  (
@@ -185,30 +209,41 @@ class Ci(ExitStacked):
185
209
 
186
210
  #
187
211
 
188
- test_cmds = [
189
- '(cd /project && python3 -m pytest -svv test.py)',
190
- ]
212
+ ci_cmd = dc.replace(self._cfg.cmd, s=' && '.join([
213
+ *setup_cmds,
214
+ f'({self._cfg.cmd.s})',
215
+ ]))
191
216
 
192
217
  #
193
218
 
194
- bash_src = ' && '.join([
195
- *setup_cmds,
196
- *test_cmds,
197
- ])
198
-
199
219
  with DockerComposeRun(DockerComposeRun.Config(
200
- compose_file=self._cfg.compose_file,
201
- service=self._cfg.service,
220
+ compose_file=self._cfg.compose_file,
221
+ service=self._cfg.service,
202
222
 
203
- image=ci_image,
223
+ image=self.resolve_ci_image(),
204
224
 
205
- run_cmd=['bash', '-c', bash_src],
225
+ cmd=ci_cmd,
206
226
 
207
- run_options=[
208
- '-v', f'{os.path.abspath(self._cfg.project_dir)}:/project',
209
- '-v', f'{os.path.abspath(requirements_dir)}:/requirements',
210
- ],
227
+ run_options=[
228
+ '-v', f'{os.path.abspath(self._cfg.project_dir)}:/project',
229
+ '-v', f'{os.path.abspath(self.resolve_requirements_dir())}:/requirements',
230
+ ],
211
231
 
212
- cwd=self._cfg.project_dir,
232
+ cwd=self._cfg.project_dir,
213
233
  )) as ci_compose_run:
214
234
  ci_compose_run.run()
235
+
236
+ def _run_compose(self) -> None:
237
+ with log_timing_context('Run compose'):
238
+ self._run_compose_()
239
+
240
+ #
241
+
242
+ def run(self) -> None:
243
+ self.load_compose_service_dependencies()
244
+
245
+ self.resolve_ci_image()
246
+
247
+ self.resolve_requirements_dir()
248
+
249
+ self._run_compose()
omdev/ci/cli.py CHANGED
@@ -20,15 +20,18 @@ from omlish.argparse.cli import ArgparseCli
20
20
  from omlish.argparse.cli import argparse_arg
21
21
  from omlish.argparse.cli import argparse_cmd
22
22
  from omlish.lite.check import check
23
+ from omlish.logs.standard import configure_standard_logging
23
24
 
24
25
  from .cache import DirectoryFileCache
26
+ from .cache import DirectoryShellCache
25
27
  from .cache import FileCache
28
+ from .cache import ShellCache
26
29
  from .ci import Ci
27
30
  from .compose import get_compose_service_dependencies
31
+ from .github.cache import GithubShellCache
32
+ from .github.cli import GithubCli
28
33
  from .requirements import build_requirements_hash
29
-
30
-
31
- ##
34
+ from .shell import ShellCmd
32
35
 
33
36
 
34
37
  class CiCli(ArgparseCli):
@@ -59,23 +62,32 @@ class CiCli(ArgparseCli):
59
62
 
60
63
  #
61
64
 
65
+ @argparse_cmd(
66
+ accepts_unknown=True,
67
+ )
68
+ def github(self) -> ta.Optional[int]:
69
+ return GithubCli(self.unknown_args).cli_run()
70
+
71
+ #
72
+
62
73
  @argparse_cmd(
63
74
  argparse_arg('project-dir'),
64
75
  argparse_arg('service'),
65
76
  argparse_arg('--docker-file'),
66
77
  argparse_arg('--compose-file'),
67
78
  argparse_arg('-r', '--requirements-txt', action='append'),
79
+ argparse_arg('--github-cache', action='store_true'),
68
80
  argparse_arg('--cache-dir'),
81
+ argparse_arg('--always-pull', action='store_true'),
69
82
  )
70
83
  async def run(self) -> None:
71
- await asyncio.sleep(1)
72
-
73
84
  project_dir = self.args.project_dir
74
85
  docker_file = self.args.docker_file
75
86
  compose_file = self.args.compose_file
76
87
  service = self.args.service
77
88
  requirements_txts = self.args.requirements_txt
78
89
  cache_dir = self.args.cache_dir
90
+ always_pull = self.args.always_pull
79
91
 
80
92
  #
81
93
 
@@ -85,7 +97,7 @@ class CiCli(ArgparseCli):
85
97
 
86
98
  def find_alt_file(*alts: str) -> ta.Optional[str]:
87
99
  for alt in alts:
88
- alt_file = os.path.join(project_dir, alt)
100
+ alt_file = os.path.abspath(os.path.join(project_dir, alt))
89
101
  if os.path.isfile(alt_file):
90
102
  return alt_file
91
103
  return None
@@ -121,24 +133,44 @@ class CiCli(ArgparseCli):
121
133
 
122
134
  #
123
135
 
136
+ shell_cache: ta.Optional[ShellCache] = None
124
137
  file_cache: ta.Optional[FileCache] = None
125
138
  if cache_dir is not None:
126
139
  if not os.path.exists(cache_dir):
127
140
  os.makedirs(cache_dir)
128
141
  check.state(os.path.isdir(cache_dir))
129
- file_cache = DirectoryFileCache(cache_dir)
142
+
143
+ directory_file_cache = DirectoryFileCache(cache_dir)
144
+
145
+ file_cache = directory_file_cache
146
+
147
+ if self.args.github_cache:
148
+ shell_cache = GithubShellCache(cache_dir)
149
+ else:
150
+ shell_cache = DirectoryShellCache(directory_file_cache)
130
151
 
131
152
  #
132
153
 
133
154
  with Ci(
134
155
  Ci.Config(
135
156
  project_dir=project_dir,
157
+
136
158
  docker_file=docker_file,
159
+
137
160
  compose_file=compose_file,
138
161
  service=service,
162
+
139
163
  requirements_txts=requirements_txts,
164
+
165
+ cmd=ShellCmd(' && '.join([
166
+ 'cd /project',
167
+ 'python3 -m pytest -svv test.py',
168
+ ])),
169
+
170
+ always_pull=always_pull,
140
171
  ),
141
172
  file_cache=file_cache,
173
+ shell_cache=shell_cache,
142
174
  ) as ci:
143
175
  ci.run()
144
176
 
@@ -148,6 +180,8 @@ async def _async_main() -> ta.Optional[int]:
148
180
 
149
181
 
150
182
  def _main() -> None:
183
+ configure_standard_logging('DEBUG')
184
+
151
185
  sys.exit(rc if isinstance(rc := asyncio.run(_async_main()), int) else 0)
152
186
 
153
187
 
omdev/ci/compose.py CHANGED
@@ -4,8 +4,11 @@
4
4
  TODO:
5
5
  - fix rmi - only when not referenced anymore
6
6
  """
7
+ import contextlib
7
8
  import dataclasses as dc
9
+ import itertools
8
10
  import os.path
11
+ import shlex
9
12
  import typing as ta
10
13
 
11
14
  from omlish.lite.cached import cached_nullary
@@ -15,6 +18,7 @@ from omlish.lite.contextmanagers import defer
15
18
  from omlish.lite.json import json_dumps_pretty
16
19
  from omlish.subprocesses import subprocesses
17
20
 
21
+ from .shell import ShellCmd
18
22
  from .utils import make_temp_file
19
23
  from .utils import read_yaml_file
20
24
 
@@ -50,7 +54,7 @@ class DockerComposeRun(ExitStacked):
50
54
 
51
55
  image: str
52
56
 
53
- run_cmd: ta.Sequence[str]
57
+ cmd: ShellCmd
54
58
 
55
59
  #
56
60
 
@@ -60,9 +64,11 @@ class DockerComposeRun(ExitStacked):
60
64
 
61
65
  #
62
66
 
63
- def __post_init__(self) -> None:
64
- check.not_isinstance(self.run_cmd, str)
67
+ no_dependency_cleanup: bool = False
68
+
69
+ #
65
70
 
71
+ def __post_init__(self) -> None:
66
72
  check.not_isinstance(self.run_options, str)
67
73
 
68
74
  def __init__(self, cfg: Config) -> None:
@@ -171,28 +177,38 @@ class DockerComposeRun(ExitStacked):
171
177
 
172
178
  #
173
179
 
180
+ def _cleanup_dependencies(self) -> None:
181
+ subprocesses.check_call(
182
+ 'docker',
183
+ 'compose',
184
+ '-f', self.rewrite_compose_file(),
185
+ 'down',
186
+ )
187
+
174
188
  def run(self) -> None:
175
189
  self.tag_image()
176
190
 
177
191
  compose_file = self.rewrite_compose_file()
178
192
 
179
- try:
180
- subprocesses.check_call(
193
+ with contextlib.ExitStack() as es:
194
+ if not self._cfg.no_dependency_cleanup:
195
+ es.enter_context(defer(self._cleanup_dependencies)) # noqa
196
+
197
+ sh_cmd = ' '.join([
181
198
  'docker',
182
199
  'compose',
183
200
  '-f', compose_file,
184
201
  'run',
185
202
  '--rm',
186
- *self._cfg.run_options or [],
203
+ *itertools.chain.from_iterable(['-e', k] for k in (self._cfg.cmd.env or [])),
204
+ *(self._cfg.run_options or []),
187
205
  self._cfg.service,
188
- *self._cfg.run_cmd,
189
- **self._subprocess_kwargs,
190
- )
206
+ 'sh', '-c', shlex.quote(self._cfg.cmd.s),
207
+ ])
191
208
 
192
- finally:
193
- subprocesses.check_call(
194
- 'docker',
195
- 'compose',
196
- '-f', compose_file,
197
- 'down',
209
+ run_cmd = dc.replace(self._cfg.cmd, s=sh_cmd)
210
+
211
+ run_cmd.run(
212
+ subprocesses.check_call,
213
+ **self._subprocess_kwargs,
198
214
  )