omdev 0.0.0.dev213__py3-none-any.whl → 0.0.0.dev215__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/.manifests.json CHANGED
@@ -267,7 +267,7 @@
267
267
  "module": ".tools.docker",
268
268
  "attr": "_CLI_MODULE",
269
269
  "file": "omdev/tools/docker.py",
270
- "line": 258,
270
+ "line": 264,
271
271
  "value": {
272
272
  "$.cli.types.CliModule": {
273
273
  "cmd_name": "docker",
omdev/ci/__init__.py CHANGED
@@ -0,0 +1 @@
1
+ # @omlish-lite
omdev/ci/cache.py CHANGED
@@ -1,12 +1,14 @@
1
1
  # ruff: noqa: UP006 UP007
2
- # @omlish-lite
3
2
  import abc
4
3
  import os.path
5
- import shlex
6
4
  import shutil
7
5
  import typing as ta
8
6
 
9
- from .shell import ShellCmd
7
+ from omlish.lite.cached import cached_nullary
8
+ from omlish.lite.check import check
9
+ from omlish.lite.logs import log
10
+
11
+ from .consts import CI_CACHE_VERSION
10
12
 
11
13
 
12
14
  ##
@@ -14,12 +16,35 @@ from .shell import ShellCmd
14
16
 
15
17
  @abc.abstractmethod
16
18
  class FileCache(abc.ABC):
19
+ def __init__(
20
+ self,
21
+ *,
22
+ version: int = CI_CACHE_VERSION,
23
+ ) -> None:
24
+ super().__init__()
25
+
26
+ check.isinstance(version, int)
27
+ check.arg(version >= 0)
28
+ self._version = version
29
+
30
+ @property
31
+ def version(self) -> int:
32
+ return self._version
33
+
34
+ #
35
+
17
36
  @abc.abstractmethod
18
- def get_file(self, key: str) -> ta.Optional[str]:
37
+ def get_file(self, key: str) -> ta.Awaitable[ta.Optional[str]]:
19
38
  raise NotImplementedError
20
39
 
21
40
  @abc.abstractmethod
22
- def put_file(self, key: str, file_path: str) -> ta.Optional[str]:
41
+ def put_file(
42
+ self,
43
+ key: str,
44
+ file_path: str,
45
+ *,
46
+ steal: bool = False,
47
+ ) -> ta.Awaitable[str]:
23
48
  raise NotImplementedError
24
49
 
25
50
 
@@ -27,142 +52,96 @@ class FileCache(abc.ABC):
27
52
 
28
53
 
29
54
  class DirectoryFileCache(FileCache):
30
- def __init__(self, dir: str) -> None: # noqa
31
- super().__init__()
32
-
33
- self._dir = dir
34
-
35
- #
36
-
37
- def get_cache_file_path(
55
+ def __init__(
38
56
  self,
39
- key: str,
57
+ dir: str, # noqa
40
58
  *,
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)
59
+ no_create: bool = False,
60
+ no_purge: bool = False,
61
+ **kwargs: ta.Any,
62
+ ) -> None: # noqa
63
+ super().__init__(**kwargs)
46
64
 
47
- def format_incomplete_file(self, f: str) -> str:
48
- return os.path.join(os.path.dirname(f), f'_{os.path.basename(f)}.incomplete')
65
+ self._dir = dir
66
+ self._no_create = no_create
67
+ self._no_purge = no_purge
49
68
 
50
69
  #
51
70
 
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):
55
- return None
56
- return cache_file_path
57
-
58
- def put_file(self, key: str, file_path: str) -> None:
59
- cache_file_path = self.get_cache_file_path(key, make_dirs=True)
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
71
+ VERSION_FILE_NAME = '.ci-cache-version'
80
72
 
81
- #
73
+ @cached_nullary
74
+ def setup_dir(self) -> None:
75
+ version_file = os.path.join(self._dir, self.VERSION_FILE_NAME)
82
76
 
83
- @property
84
- @abc.abstractmethod
85
- def cmd(self) -> ShellCmd:
86
- raise NotImplementedError
77
+ if self._no_create:
78
+ check.state(os.path.isdir(self._dir))
87
79
 
88
- #
80
+ elif not os.path.isdir(self._dir):
81
+ os.makedirs(self._dir)
82
+ with open(version_file, 'w') as f:
83
+ f.write(str(self._version))
84
+ return
89
85
 
90
- def __enter__(self):
91
- return self
86
+ with open(version_file) as f:
87
+ dir_version = int(f.read().strip())
92
88
 
93
- def __exit__(self, exc_type, exc_val, exc_tb):
94
- if exc_val is None:
95
- self.commit()
96
- else:
97
- self.abort()
89
+ if dir_version == self._version:
90
+ return
98
91
 
99
- #
92
+ if self._no_purge:
93
+ raise RuntimeError(f'{dir_version=} != {self._version=}')
100
94
 
101
- @abc.abstractmethod
102
- def _commit(self) -> None:
103
- raise NotImplementedError
95
+ dirs = [n for n in sorted(os.listdir(self._dir)) if os.path.isdir(os.path.join(self._dir, n))]
96
+ if dirs:
97
+ raise RuntimeError(
98
+ f'Refusing to remove stale cache dir {self._dir!r} '
99
+ f'due to present directories: {", ".join(dirs)}',
100
+ )
104
101
 
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)
102
+ for n in sorted(os.listdir(self._dir)):
103
+ if n.startswith('.'):
104
+ continue
105
+ fp = os.path.join(self._dir, n)
106
+ check.state(os.path.isfile(fp))
107
+ log.debug('Purging stale cache file: %s', fp)
108
+ os.unlink(fp)
113
109
 
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
110
+ os.unlink(version_file)
132
111
 
112
+ with open(version_file, 'w') as f:
113
+ f.write(str(self._version))
133
114
 
134
- #
115
+ #
135
116
 
117
+ def get_cache_file_path(
118
+ self,
119
+ key: str,
120
+ ) -> str:
121
+ self.setup_dir()
122
+ return os.path.join(self._dir, key)
136
123
 
137
- class DirectoryShellCache(ShellCache):
138
- def __init__(self, dfc: DirectoryFileCache) -> None:
139
- super().__init__()
124
+ def format_incomplete_file(self, f: str) -> str:
125
+ return os.path.join(os.path.dirname(f), f'_{os.path.basename(f)}.incomplete')
140
126
 
141
- self._dfc = dfc
127
+ #
142
128
 
143
- def get_file_cmd(self, key: str) -> ta.Optional[ShellCmd]:
144
- f = self._dfc.get_file(key)
145
- if f is None:
129
+ async def get_file(self, key: str) -> ta.Optional[str]:
130
+ cache_file_path = self.get_cache_file_path(key)
131
+ if not os.path.exists(cache_file_path):
146
132
  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)
133
+ return cache_file_path
165
134
 
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)
135
+ async def put_file(
136
+ self,
137
+ key: str,
138
+ file_path: str,
139
+ *,
140
+ steal: bool = False,
141
+ ) -> str:
142
+ cache_file_path = self.get_cache_file_path(key)
143
+ if steal:
144
+ shutil.move(file_path, cache_file_path)
145
+ else:
146
+ shutil.copyfile(file_path, cache_file_path)
147
+ return cache_file_path
omdev/ci/ci.py CHANGED
@@ -1,20 +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
 
10
6
  from omlish.lite.cached import async_cached_nullary
11
7
  from omlish.lite.cached import cached_nullary
12
8
  from omlish.lite.check import check
13
9
  from omlish.lite.contextmanagers import AsyncExitStacked
14
- from omlish.lite.contextmanagers import defer
10
+ from omlish.os.temp import temp_file_context
15
11
 
16
12
  from .cache import FileCache
17
- from .cache import ShellCache
18
13
  from .compose import DockerComposeRun
19
14
  from .compose import get_compose_service_dependencies
20
15
  from .docker import build_docker_file_hash
@@ -25,13 +20,12 @@ from .docker import pull_docker_image
25
20
  from .docker import save_docker_tar_cmd
26
21
  from .docker import tag_docker_image
27
22
  from .requirements import build_requirements_hash
28
- from .requirements import download_requirements
29
23
  from .shell import ShellCmd
30
24
  from .utils import log_timing_context
31
25
 
32
26
 
33
27
  class Ci(AsyncExitStacked):
34
- FILE_NAME_HASH_LEN = 16
28
+ KEY_HASH_LEN = 16
35
29
 
36
30
  @dc.dataclass(frozen=True)
37
31
  class Config:
@@ -44,6 +38,8 @@ class Ci(AsyncExitStacked):
44
38
 
45
39
  cmd: ShellCmd
46
40
 
41
+ #
42
+
47
43
  requirements_txts: ta.Optional[ta.Sequence[str]] = None
48
44
 
49
45
  always_pull: bool = False
@@ -51,6 +47,10 @@ class Ci(AsyncExitStacked):
51
47
 
52
48
  no_dependencies: bool = False
53
49
 
50
+ run_options: ta.Optional[ta.Sequence[str]] = None
51
+
52
+ #
53
+
54
54
  def __post_init__(self) -> None:
55
55
  check.not_isinstance(self.requirements_txts, str)
56
56
 
@@ -58,42 +58,15 @@ class Ci(AsyncExitStacked):
58
58
  self,
59
59
  cfg: Config,
60
60
  *,
61
- shell_cache: ta.Optional[ShellCache] = None,
62
61
  file_cache: ta.Optional[FileCache] = None,
63
62
  ) -> None:
64
63
  super().__init__()
65
64
 
66
65
  self._cfg = cfg
67
- self._shell_cache = shell_cache
68
66
  self._file_cache = file_cache
69
67
 
70
68
  #
71
69
 
72
- async def _load_cache_docker_image(self, key: str) -> ta.Optional[str]:
73
- if self._shell_cache is None:
74
- return None
75
-
76
- get_cache_cmd = self._shell_cache.get_file_cmd(key)
77
- if get_cache_cmd is None:
78
- return None
79
-
80
- get_cache_cmd = dc.replace(get_cache_cmd, s=f'{get_cache_cmd.s} | zstd -cd --long') # noqa
81
-
82
- return await load_docker_tar_cmd(get_cache_cmd)
83
-
84
- async def _save_cache_docker_image(self, key: str, image: str) -> None:
85
- if self._shell_cache is None:
86
- return
87
-
88
- with self._shell_cache.put_file_cmd(key) as put_cache:
89
- put_cache_cmd = put_cache.cmd
90
-
91
- put_cache_cmd = dc.replace(put_cache_cmd, s=f'zstd | {put_cache_cmd.s}')
92
-
93
- await save_docker_tar_cmd(image, put_cache_cmd)
94
-
95
- #
96
-
97
70
  async def _load_docker_image(self, image: str) -> None:
98
71
  if not self._cfg.always_pull and (await is_docker_image_present(image)):
99
72
  return
@@ -114,24 +87,38 @@ class Ci(AsyncExitStacked):
114
87
  with log_timing_context(f'Load docker image: {image}'):
115
88
  await self._load_docker_image(image)
116
89
 
117
- @async_cached_nullary
118
- async def load_compose_service_dependencies(self) -> None:
119
- deps = get_compose_service_dependencies(
120
- self._cfg.compose_file,
121
- self._cfg.service,
122
- )
90
+ #
123
91
 
124
- for dep_image in deps.values():
125
- await self.load_docker_image(dep_image)
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
- #
96
+ cache_file = await self._file_cache.get_file(key)
97
+ if cache_file is None:
98
+ return None
128
99
 
129
- @cached_nullary
130
- def docker_file_hash(self) -> str:
131
- return build_docker_file_hash(self._cfg.docker_file)[:self.FILE_NAME_HASH_LEN]
100
+ get_cache_cmd = ShellCmd(f'cat {cache_file} | zstd -cd --long')
132
101
 
133
- async def _resolve_ci_image(self) -> str:
134
- cache_key = f'ci-{self.docker_file_hash()}'
102
+ return await load_docker_tar_cmd(get_cache_cmd)
103
+
104
+ async def _save_cache_docker_image(self, key: str, image: str) -> None:
105
+ if self._file_cache is None:
106
+ return
107
+
108
+ with temp_file_context() as tmp_file:
109
+ write_tmp_cmd = ShellCmd(f'zstd > {tmp_file}')
110
+
111
+ await save_docker_tar_cmd(image, write_tmp_cmd)
112
+
113
+ await self._file_cache.put_file(key, tmp_file, steal=True)
114
+
115
+ #
116
+
117
+ async def _resolve_docker_image(
118
+ self,
119
+ cache_key: str,
120
+ build_and_tag: ta.Callable[[str], ta.Awaitable[str]],
121
+ ) -> str:
135
122
  image_tag = f'{self._cfg.service}:{cache_key}'
136
123
 
137
124
  if not self._cfg.always_build and (await is_docker_image_present(image_tag)):
@@ -144,21 +131,35 @@ class Ci(AsyncExitStacked):
144
131
  )
145
132
  return image_tag
146
133
 
147
- image_id = await build_docker_image(
148
- self._cfg.docker_file,
149
- tag=image_tag,
150
- cwd=self._cfg.project_dir,
151
- )
134
+ image_id = await build_and_tag(image_tag)
152
135
 
153
136
  await self._save_cache_docker_image(cache_key, image_id)
154
137
 
155
138
  return image_tag
156
139
 
140
+ #
141
+
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
+
157
158
  @async_cached_nullary
158
- async def resolve_ci_image(self) -> str:
159
- with log_timing_context('Resolve ci image') as ltc:
160
- image_id = await self._resolve_ci_image()
161
- ltc.set_description(f'Resolve ci image: {image_id}')
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}')
162
163
  return image_id
163
164
 
164
165
  #
@@ -172,82 +173,85 @@ class Ci(AsyncExitStacked):
172
173
 
173
174
  @cached_nullary
174
175
  def requirements_hash(self) -> str:
175
- return build_requirements_hash(self.requirements_txts())[:self.FILE_NAME_HASH_LEN]
176
-
177
- async def _resolve_requirements_dir(self) -> str:
178
- tar_file_key = f'requirements-{self.docker_file_hash()}-{self.requirements_hash()}'
179
- tar_file_name = f'{tar_file_key}.tar'
180
-
181
- temp_dir = tempfile.mkdtemp()
182
- self._enter_context(defer(lambda: shutil.rmtree(temp_dir))) # noqa
176
+ return build_requirements_hash(self.requirements_txts())[:self.KEY_HASH_LEN]
183
177
 
184
- if self._file_cache is not None and (cache_tar_file := self._file_cache.get_file(tar_file_key)):
185
- with tarfile.open(cache_tar_file) as tar:
186
- tar.extractall(path=temp_dir) # noqa
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)
187
221
 
188
- return temp_dir
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
189
228
 
190
- temp_requirements_dir = os.path.join(temp_dir, 'requirements')
191
- os.makedirs(temp_requirements_dir)
229
+ #
192
230
 
193
- download_requirements(
194
- await self.resolve_ci_image(),
195
- temp_requirements_dir,
196
- self.requirements_txts(),
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,
197
236
  )
198
237
 
199
- if self._file_cache is not None:
200
- temp_tar_file = os.path.join(temp_dir, tar_file_name)
201
-
202
- with tarfile.open(temp_tar_file, 'w') as tar:
203
- for requirement_file in os.listdir(temp_requirements_dir):
204
- tar.add(
205
- os.path.join(temp_requirements_dir, requirement_file),
206
- arcname=requirement_file,
207
- )
208
-
209
- self._file_cache.put_file(os.path.basename(tar_file_key), temp_tar_file)
210
-
211
- return temp_requirements_dir
212
-
213
- @async_cached_nullary
214
- async def resolve_requirements_dir(self) -> str:
215
- with log_timing_context('Resolve requirements dir') as ltc:
216
- requirements_dir = await self._resolve_requirements_dir()
217
- ltc.set_description(f'Resolve requirements dir: {requirements_dir}')
218
- return requirements_dir
238
+ for dep_image in deps.values():
239
+ await self.load_docker_image(dep_image)
219
240
 
220
241
  #
221
242
 
222
243
  async def _run_compose_(self) -> None:
223
- setup_cmds = [
224
- 'pip install --root-user-action ignore --find-links /requirements --no-index uv',
225
- (
226
- 'uv pip install --system --find-links /requirements ' +
227
- ' '.join(f'-r /project/{rf}' for rf in self._cfg.requirements_txts or [])
228
- ),
229
- ]
230
-
231
- #
232
-
233
- ci_cmd = dc.replace(self._cfg.cmd, s=' && '.join([
234
- *setup_cmds,
235
- f'({self._cfg.cmd.s})',
236
- ]))
237
-
238
- #
239
-
240
244
  async with DockerComposeRun(DockerComposeRun.Config(
241
245
  compose_file=self._cfg.compose_file,
242
246
  service=self._cfg.service,
243
247
 
244
248
  image=await self.resolve_ci_image(),
245
249
 
246
- cmd=ci_cmd,
250
+ cmd=self._cfg.cmd,
247
251
 
248
252
  run_options=[
249
253
  '-v', f'{os.path.abspath(self._cfg.project_dir)}:/project',
250
- '-v', f'{os.path.abspath(await self.resolve_requirements_dir())}:/requirements',
254
+ *(self._cfg.run_options or []),
251
255
  ],
252
256
 
253
257
  cwd=self._cfg.project_dir,
@@ -263,10 +267,8 @@ class Ci(AsyncExitStacked):
263
267
  #
264
268
 
265
269
  async def run(self) -> None:
266
- await self.load_compose_service_dependencies()
267
-
268
270
  await self.resolve_ci_image()
269
271
 
270
- await self.resolve_requirements_dir()
272
+ await self.load_dependencies()
271
273
 
272
274
  await self._run_compose()