omdev 0.0.0.dev212__py3-none-any.whl → 0.0.0.dev214__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/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]: