omdev 0.0.0.dev212__py3-none-any.whl → 0.0.0.dev214__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
omdev/ci/ci.py CHANGED
@@ -1,19 +1,15 @@
1
1
  # ruff: noqa: UP006 UP007
2
- # @omlish-lite
3
2
  import dataclasses as dc
4
3
  import os.path
5
- import shutil
6
- import tarfile
7
- import tempfile
8
4
  import typing as ta
9
5
 
6
+ from omlish.lite.cached import async_cached_nullary
10
7
  from omlish.lite.cached import cached_nullary
11
8
  from omlish.lite.check import check
12
- from omlish.lite.contextmanagers import ExitStacked
13
- from omlish.lite.contextmanagers import defer
9
+ from omlish.lite.contextmanagers import AsyncExitStacked
10
+ from omlish.os.temp import temp_file_context
14
11
 
15
12
  from .cache import FileCache
16
- from .cache import ShellCache
17
13
  from .compose import DockerComposeRun
18
14
  from .compose import get_compose_service_dependencies
19
15
  from .docker import build_docker_file_hash
@@ -22,14 +18,14 @@ from .docker import is_docker_image_present
22
18
  from .docker import load_docker_tar_cmd
23
19
  from .docker import pull_docker_image
24
20
  from .docker import save_docker_tar_cmd
21
+ from .docker import tag_docker_image
25
22
  from .requirements import build_requirements_hash
26
- from .requirements import download_requirements
27
23
  from .shell import ShellCmd
28
24
  from .utils import log_timing_context
29
25
 
30
26
 
31
- class Ci(ExitStacked):
32
- FILE_NAME_HASH_LEN = 16
27
+ class Ci(AsyncExitStacked):
28
+ KEY_HASH_LEN = 16
33
29
 
34
30
  @dc.dataclass(frozen=True)
35
31
  class Config:
@@ -42,9 +38,18 @@ class Ci(ExitStacked):
42
38
 
43
39
  cmd: ShellCmd
44
40
 
41
+ #
42
+
45
43
  requirements_txts: ta.Optional[ta.Sequence[str]] = None
46
44
 
47
45
  always_pull: bool = False
46
+ always_build: bool = False
47
+
48
+ no_dependencies: bool = False
49
+
50
+ run_options: ta.Optional[ta.Sequence[str]] = None
51
+
52
+ #
48
53
 
49
54
  def __post_init__(self) -> None:
50
55
  check.not_isinstance(self.requirements_txts, str)
@@ -53,44 +58,17 @@ class Ci(ExitStacked):
53
58
  self,
54
59
  cfg: Config,
55
60
  *,
56
- shell_cache: ta.Optional[ShellCache] = None,
57
61
  file_cache: ta.Optional[FileCache] = None,
58
62
  ) -> None:
59
63
  super().__init__()
60
64
 
61
65
  self._cfg = cfg
62
- self._shell_cache = shell_cache
63
66
  self._file_cache = file_cache
64
67
 
65
68
  #
66
69
 
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):
70
+ async def _load_docker_image(self, image: str) -> None:
71
+ if not self._cfg.always_pull and (await is_docker_image_present(image)):
94
72
  return
95
73
 
96
74
  dep_suffix = image
@@ -98,152 +76,199 @@ class Ci(ExitStacked):
98
76
  dep_suffix = dep_suffix.replace(c, '-')
99
77
 
100
78
  cache_key = f'docker-{dep_suffix}'
101
- if self._load_cache_docker_image(cache_key) is not None:
79
+ if (await self._load_cache_docker_image(cache_key)) is not None:
102
80
  return
103
81
 
104
- pull_docker_image(image)
82
+ await pull_docker_image(image)
105
83
 
106
- self._save_cache_docker_image(cache_key, image)
84
+ await self._save_cache_docker_image(cache_key, image)
107
85
 
108
- def load_docker_image(self, image: str) -> None:
86
+ async def load_docker_image(self, image: str) -> None:
109
87
  with log_timing_context(f'Load docker image: {image}'):
110
- self._load_docker_image(image)
111
-
112
- @cached_nullary
113
- def load_compose_service_dependencies(self) -> None:
114
- deps = get_compose_service_dependencies(
115
- self._cfg.compose_file,
116
- self._cfg.service,
117
- )
118
-
119
- for dep_image in deps.values():
120
- self.load_docker_image(dep_image)
88
+ await self._load_docker_image(image)
121
89
 
122
90
  #
123
91
 
124
- def _resolve_ci_image(self) -> str:
125
- docker_file_hash = build_docker_file_hash(self._cfg.docker_file)[:self.FILE_NAME_HASH_LEN]
92
+ async def _load_cache_docker_image(self, key: str) -> ta.Optional[str]:
93
+ if self._file_cache is None:
94
+ return None
126
95
 
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
96
+ cache_file = await self._file_cache.get_file(key)
97
+ if cache_file is None:
98
+ return None
130
99
 
131
- image_id = build_docker_image(
132
- self._cfg.docker_file,
133
- cwd=self._cfg.project_dir,
134
- )
100
+ get_cache_cmd = ShellCmd(f'cat {cache_file} | zstd -cd --long')
135
101
 
136
- self._save_cache_docker_image(cache_key, image_id)
102
+ return await load_docker_tar_cmd(get_cache_cmd)
137
103
 
138
- return image_id
104
+ async def _save_cache_docker_image(self, key: str, image: str) -> None:
105
+ if self._file_cache is None:
106
+ return
139
107
 
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}')
145
- return image_id
108
+ with temp_file_context() as tmp_file:
109
+ write_tmp_cmd = ShellCmd(f'zstd > {tmp_file}')
146
110
 
147
- #
111
+ await save_docker_tar_cmd(image, write_tmp_cmd)
148
112
 
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
- ]
113
+ await self._file_cache.put_file(key, tmp_file, steal=True)
154
114
 
155
- requirements_hash = build_requirements_hash(requirements_txts)[:self.FILE_NAME_HASH_LEN]
115
+ #
156
116
 
157
- tar_file_key = f'requirements-{requirements_hash}'
158
- tar_file_name = f'{tar_file_key}.tar'
117
+ async def _resolve_docker_image(
118
+ self,
119
+ cache_key: str,
120
+ build_and_tag: ta.Callable[[str], ta.Awaitable[str]],
121
+ ) -> str:
122
+ image_tag = f'{self._cfg.service}:{cache_key}'
159
123
 
160
- temp_dir = tempfile.mkdtemp()
161
- self._enter_context(defer(lambda: shutil.rmtree(temp_dir))) # noqa
124
+ if not self._cfg.always_build and (await is_docker_image_present(image_tag)):
125
+ return image_tag
162
126
 
163
- if self._file_cache is not None and (cache_tar_file := self._file_cache.get_file(tar_file_key)):
164
- with tarfile.open(cache_tar_file) as tar:
165
- tar.extractall(path=temp_dir) # noqa
127
+ if (cache_image_id := await self._load_cache_docker_image(cache_key)) is not None:
128
+ await tag_docker_image(
129
+ cache_image_id,
130
+ image_tag,
131
+ )
132
+ return image_tag
166
133
 
167
- return temp_dir
134
+ image_id = await build_and_tag(image_tag)
168
135
 
169
- temp_requirements_dir = os.path.join(temp_dir, 'requirements')
170
- os.makedirs(temp_requirements_dir)
136
+ await self._save_cache_docker_image(cache_key, image_id)
171
137
 
172
- download_requirements(
173
- self.resolve_ci_image(),
174
- temp_requirements_dir,
175
- requirements_txts,
176
- )
138
+ return image_tag
177
139
 
178
- if self._file_cache is not None:
179
- temp_tar_file = os.path.join(temp_dir, tar_file_name)
140
+ #
180
141
 
181
- with tarfile.open(temp_tar_file, 'w') as tar:
182
- for requirement_file in os.listdir(temp_requirements_dir):
183
- tar.add(
184
- os.path.join(temp_requirements_dir, requirement_file),
185
- arcname=requirement_file,
186
- )
142
+ @cached_nullary
143
+ def docker_file_hash(self) -> str:
144
+ return build_docker_file_hash(self._cfg.docker_file)[:self.KEY_HASH_LEN]
145
+
146
+ async def _resolve_ci_base_image(self) -> str:
147
+ async def build_and_tag(image_tag: str) -> str:
148
+ return await build_docker_image(
149
+ self._cfg.docker_file,
150
+ tag=image_tag,
151
+ cwd=self._cfg.project_dir,
152
+ )
153
+
154
+ cache_key = f'ci-base-{self.docker_file_hash()}'
155
+
156
+ return await self._resolve_docker_image(cache_key, build_and_tag)
157
+
158
+ @async_cached_nullary
159
+ async def resolve_ci_base_image(self) -> str:
160
+ with log_timing_context('Resolve ci base image') as ltc:
161
+ image_id = await self._resolve_ci_base_image()
162
+ ltc.set_description(f'Resolve ci base image: {image_id}')
163
+ return image_id
187
164
 
188
- self._file_cache.put_file(os.path.basename(tar_file_key), temp_tar_file)
165
+ #
189
166
 
190
- return temp_requirements_dir
167
+ @cached_nullary
168
+ def requirements_txts(self) -> ta.Sequence[str]:
169
+ return [
170
+ os.path.join(self._cfg.project_dir, rf)
171
+ for rf in check.not_none(self._cfg.requirements_txts)
172
+ ]
191
173
 
192
174
  @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
+ def requirements_hash(self) -> str:
176
+ return build_requirements_hash(self.requirements_txts())[:self.KEY_HASH_LEN]
177
+
178
+ async def _resolve_ci_image(self) -> str:
179
+ async def build_and_tag(image_tag: str) -> str:
180
+ base_image = await self.resolve_ci_base_image()
181
+
182
+ setup_cmds = [
183
+ ' '.join([
184
+ 'pip install',
185
+ '--no-cache-dir',
186
+ '--root-user-action ignore',
187
+ 'uv',
188
+ ]),
189
+ ' '.join([
190
+ 'uv pip install',
191
+ '--no-cache',
192
+ '--index-strategy unsafe-best-match',
193
+ '--system',
194
+ *[f'-r /project/{rf}' for rf in self._cfg.requirements_txts or []],
195
+ ]),
196
+ ]
197
+ setup_cmd = ' && '.join(setup_cmds)
198
+
199
+ docker_file_lines = [
200
+ f'FROM {base_image}',
201
+ 'RUN mkdir /project',
202
+ *[f'COPY {rf} /project/{rf}' for rf in self._cfg.requirements_txts or []],
203
+ f'RUN {setup_cmd}',
204
+ 'RUN rm /project/*',
205
+ 'WORKDIR /project',
206
+ ]
207
+
208
+ with temp_file_context() as docker_file:
209
+ with open(docker_file, 'w') as f: # noqa
210
+ f.write('\n'.join(docker_file_lines))
211
+
212
+ return await build_docker_image(
213
+ docker_file,
214
+ tag=image_tag,
215
+ cwd=self._cfg.project_dir,
216
+ )
217
+
218
+ cache_key = f'ci-{self.docker_file_hash()}-{self.requirements_hash()}'
219
+
220
+ return await self._resolve_docker_image(cache_key, build_and_tag)
221
+
222
+ @async_cached_nullary
223
+ async def resolve_ci_image(self) -> str:
224
+ with log_timing_context('Resolve ci image') as ltc:
225
+ image_id = await self._resolve_ci_image()
226
+ ltc.set_description(f'Resolve ci image: {image_id}')
227
+ return image_id
198
228
 
199
229
  #
200
230
 
201
- def _run_compose_(self) -> None:
202
- setup_cmds = [
203
- 'pip install --root-user-action ignore --find-links /requirements --no-index uv',
204
- (
205
- 'uv pip install --system --find-links /requirements ' +
206
- ' '.join(f'-r /project/{rf}' for rf in self._cfg.requirements_txts or [])
207
- ),
208
- ]
209
-
210
- #
231
+ @async_cached_nullary
232
+ async def load_dependencies(self) -> None:
233
+ deps = get_compose_service_dependencies(
234
+ self._cfg.compose_file,
235
+ self._cfg.service,
236
+ )
211
237
 
212
- ci_cmd = dc.replace(self._cfg.cmd, s=' && '.join([
213
- *setup_cmds,
214
- f'({self._cfg.cmd.s})',
215
- ]))
238
+ for dep_image in deps.values():
239
+ await self.load_docker_image(dep_image)
216
240
 
217
- #
241
+ #
218
242
 
219
- with DockerComposeRun(DockerComposeRun.Config(
243
+ async def _run_compose_(self) -> None:
244
+ async with DockerComposeRun(DockerComposeRun.Config(
220
245
  compose_file=self._cfg.compose_file,
221
246
  service=self._cfg.service,
222
247
 
223
- image=self.resolve_ci_image(),
248
+ image=await self.resolve_ci_image(),
224
249
 
225
- cmd=ci_cmd,
250
+ cmd=self._cfg.cmd,
226
251
 
227
252
  run_options=[
228
253
  '-v', f'{os.path.abspath(self._cfg.project_dir)}:/project',
229
- '-v', f'{os.path.abspath(self.resolve_requirements_dir())}:/requirements',
254
+ *(self._cfg.run_options or []),
230
255
  ],
231
256
 
232
257
  cwd=self._cfg.project_dir,
258
+
259
+ no_dependencies=self._cfg.no_dependencies,
233
260
  )) as ci_compose_run:
234
- ci_compose_run.run()
261
+ await ci_compose_run.run()
235
262
 
236
- def _run_compose(self) -> None:
263
+ async def _run_compose(self) -> None:
237
264
  with log_timing_context('Run compose'):
238
- self._run_compose_()
265
+ await self._run_compose_()
239
266
 
240
267
  #
241
268
 
242
- def run(self) -> None:
243
- self.load_compose_service_dependencies()
244
-
245
- self.resolve_ci_image()
269
+ async def run(self) -> None:
270
+ await self.resolve_ci_image()
246
271
 
247
- self.resolve_requirements_dir()
272
+ await self.load_dependencies()
248
273
 
249
- self._run_compose()
274
+ await self._run_compose()
omdev/ci/cli.py CHANGED
@@ -1,6 +1,5 @@
1
1
  # @omlish-amalg ../scripts/ci.py
2
2
  # ruff: noqa: UP006 UP007
3
- # @omlish-lite
4
3
  """
5
4
  Inputs:
6
5
  - requirements.txt
@@ -9,9 +8,11 @@ Inputs:
9
8
 
10
9
  ==
11
10
 
12
- ./python -m ci run --cache-dir ci/cache ci/project omlish-ci
11
+ ./python -m omdev.ci run --cache-dir omdev/ci/tests/cache omdev/ci/tests/project omlish-ci
13
12
  """
13
+ import argparse
14
14
  import asyncio
15
+ import itertools
15
16
  import os.path
16
17
  import sys
17
18
  import typing as ta
@@ -20,15 +21,15 @@ from omlish.argparse.cli import ArgparseCli
20
21
  from omlish.argparse.cli import argparse_arg
21
22
  from omlish.argparse.cli import argparse_cmd
22
23
  from omlish.lite.check import check
24
+ from omlish.lite.logs import log
23
25
  from omlish.logs.standard import configure_standard_logging
24
26
 
25
27
  from .cache import DirectoryFileCache
26
- from .cache import DirectoryShellCache
27
28
  from .cache import FileCache
28
- from .cache import ShellCache
29
29
  from .ci import Ci
30
30
  from .compose import get_compose_service_dependencies
31
- from .github.cache import GithubShellCache
31
+ from .github.bootstrap import is_in_github_actions
32
+ from .github.cache import GithubFileCache
32
33
  from .github.cli import GithubCli
33
34
  from .requirements import build_requirements_hash
34
35
  from .shell import ShellCmd
@@ -65,8 +66,8 @@ class CiCli(ArgparseCli):
65
66
  @argparse_cmd(
66
67
  accepts_unknown=True,
67
68
  )
68
- def github(self) -> ta.Optional[int]:
69
- return GithubCli(self.unknown_args).cli_run()
69
+ async def github(self) -> ta.Optional[int]:
70
+ return await GithubCli(self.unknown_args).async_cli_run()
70
71
 
71
72
  #
72
73
 
@@ -76,18 +77,33 @@ class CiCli(ArgparseCli):
76
77
  argparse_arg('--docker-file'),
77
78
  argparse_arg('--compose-file'),
78
79
  argparse_arg('-r', '--requirements-txt', action='append'),
79
- argparse_arg('--github-cache', action='store_true'),
80
+
80
81
  argparse_arg('--cache-dir'),
82
+
83
+ argparse_arg('--github', action='store_true'),
84
+ argparse_arg('--github-detect', action='store_true'),
85
+
81
86
  argparse_arg('--always-pull', action='store_true'),
87
+ argparse_arg('--always-build', action='store_true'),
88
+
89
+ argparse_arg('--no-dependencies', action='store_true'),
90
+
91
+ argparse_arg('-e', '--env', action='append'),
92
+ argparse_arg('-v', '--volume', action='append'),
93
+
94
+ argparse_arg('cmd', nargs=argparse.REMAINDER),
82
95
  )
83
96
  async def run(self) -> None:
84
97
  project_dir = self.args.project_dir
85
98
  docker_file = self.args.docker_file
86
99
  compose_file = self.args.compose_file
87
- service = self.args.service
88
100
  requirements_txts = self.args.requirements_txt
89
101
  cache_dir = self.args.cache_dir
90
- always_pull = self.args.always_pull
102
+
103
+ #
104
+
105
+ cmd = ' '.join(self.args.cmd)
106
+ check.non_empty_str(cmd)
91
107
 
92
108
  #
93
109
 
@@ -99,6 +115,7 @@ class CiCli(ArgparseCli):
99
115
  for alt in alts:
100
116
  alt_file = os.path.abspath(os.path.join(project_dir, alt))
101
117
  if os.path.isfile(alt_file):
118
+ log.debug('Using %s', alt_file)
102
119
  return alt_file
103
120
  return None
104
121
 
@@ -132,6 +149,7 @@ class CiCli(ArgparseCli):
132
149
  'requirements-ci.txt',
133
150
  ]:
134
151
  if os.path.exists(os.path.join(project_dir, rf)):
152
+ log.debug('Using %s', rf)
135
153
  requirements_txts.append(rf)
136
154
  else:
137
155
  for rf in requirements_txts:
@@ -139,46 +157,60 @@ class CiCli(ArgparseCli):
139
157
 
140
158
  #
141
159
 
142
- shell_cache: ta.Optional[ShellCache] = None
160
+ github = self.args.github
161
+ if not github and self.args.github_detect:
162
+ github = is_in_github_actions()
163
+ if github:
164
+ log.debug('Github detected')
165
+
166
+ #
167
+
143
168
  file_cache: ta.Optional[FileCache] = None
144
169
  if cache_dir is not None:
145
- if not os.path.exists(cache_dir):
146
- os.makedirs(cache_dir)
147
- check.state(os.path.isdir(cache_dir))
148
-
149
- directory_file_cache = DirectoryFileCache(cache_dir)
170
+ cache_dir = os.path.abspath(cache_dir)
171
+ log.debug('Using cache dir %s', cache_dir)
172
+ if github:
173
+ file_cache = GithubFileCache(cache_dir)
174
+ else:
175
+ file_cache = DirectoryFileCache(cache_dir)
150
176
 
151
- file_cache = directory_file_cache
177
+ #
152
178
 
153
- if self.args.github_cache:
154
- shell_cache = GithubShellCache(cache_dir)
155
- else:
156
- shell_cache = DirectoryShellCache(directory_file_cache)
179
+ run_options: ta.List[str] = []
180
+ for run_arg, run_arg_vals in [
181
+ ('-e', self.args.env or []),
182
+ ('-v', self.args.volume or []),
183
+ ]:
184
+ run_options.extend(itertools.chain.from_iterable(
185
+ [run_arg, run_arg_val]
186
+ for run_arg_val in run_arg_vals
187
+ ))
157
188
 
158
189
  #
159
190
 
160
- with Ci(
191
+ async with Ci(
161
192
  Ci.Config(
162
193
  project_dir=project_dir,
163
194
 
164
195
  docker_file=docker_file,
165
196
 
166
197
  compose_file=compose_file,
167
- service=service,
198
+ service=self.args.service,
168
199
 
169
200
  requirements_txts=requirements_txts,
170
201
 
171
- cmd=ShellCmd(' && '.join([
172
- 'cd /project',
173
- 'python3 -m pytest -svv test.py',
174
- ])),
202
+ cmd=ShellCmd(cmd),
203
+
204
+ always_pull=self.args.always_pull,
205
+ always_build=self.args.always_build,
206
+
207
+ no_dependencies=self.args.no_dependencies,
175
208
 
176
- always_pull=always_pull,
209
+ run_options=run_options,
177
210
  ),
178
211
  file_cache=file_cache,
179
- shell_cache=shell_cache,
180
212
  ) as ci:
181
- ci.run()
213
+ await ci.run()
182
214
 
183
215
 
184
216
  async def _async_main() -> ta.Optional[int]: