omdev 0.0.0.dev223__py3-none-any.whl → 0.0.0.dev224__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,40 @@
1
+ # ruff: noqa: UP006 UP007
2
+ import abc
3
+ import contextlib
4
+ import shlex
5
+ import typing as ta
6
+
7
+ from omlish.asyncs.asyncio.subprocesses import asyncio_subprocesses
8
+ from omlish.lite.timing import log_timing_context
9
+ from omlish.os.temp import temp_dir_context
10
+
11
+ from ...oci.repositories import DirectoryOciRepository
12
+ from ...oci.repositories import OciRepository
13
+
14
+
15
+ ##
16
+
17
+
18
+ class DockerImageRepositoryOpener(abc.ABC):
19
+ @abc.abstractmethod
20
+ def open_docker_image_repository(self, image: str) -> ta.AsyncContextManager[OciRepository]:
21
+ raise NotImplementedError
22
+
23
+
24
+ #
25
+
26
+
27
+ class DockerImageRepositoryOpenerImpl(DockerImageRepositoryOpener):
28
+ @contextlib.asynccontextmanager
29
+ async def open_docker_image_repository(self, image: str) -> ta.AsyncGenerator[OciRepository, None]:
30
+ with temp_dir_context() as save_dir:
31
+ with log_timing_context(f'Saving docker image {image}'):
32
+ await asyncio_subprocesses.check_call(
33
+ ' | '.join([
34
+ f'docker save {shlex.quote(image)}',
35
+ f'tar x -C {shlex.quote(save_dir)}',
36
+ ]),
37
+ shell=True,
38
+ )
39
+
40
+ yield DirectoryOciRepository(save_dir)
omdev/ci/github/cache.py CHANGED
@@ -7,8 +7,10 @@ from omlish.lite.check import check
7
7
  from omlish.os.files import unlinking_if_exists
8
8
 
9
9
  from ..cache import CacheVersion
10
+ from ..cache import DataCache
10
11
  from ..cache import DirectoryFileCache
11
12
  from ..cache import FileCache
13
+ from ..cache import FileCacheDataCache
12
14
  from .client import GithubCacheClient
13
15
  from .client import GithubCacheServiceV1Client
14
16
 
@@ -16,7 +18,7 @@ from .client import GithubCacheServiceV1Client
16
18
  ##
17
19
 
18
20
 
19
- class GithubFileCache(FileCache):
21
+ class GithubCache(FileCache, DataCache):
20
22
  @dc.dataclass(frozen=True)
21
23
  class Config:
22
24
  dir: str
@@ -47,6 +49,8 @@ class GithubFileCache(FileCache):
47
49
  version=self._version,
48
50
  )
49
51
 
52
+ #
53
+
50
54
  async def get_file(self, key: str) -> ta.Optional[str]:
51
55
  local_file = self._local.get_cache_file_path(key)
52
56
  if os.path.exists(local_file):
@@ -79,3 +83,18 @@ class GithubFileCache(FileCache):
79
83
  await self._client.upload_file(key, cache_file_path)
80
84
 
81
85
  return cache_file_path
86
+
87
+ #
88
+
89
+ async def get_data(self, key: str) -> ta.Optional[DataCache.Data]:
90
+ local_file = self._local.get_cache_file_path(key)
91
+ if os.path.exists(local_file):
92
+ return DataCache.FileData(local_file)
93
+
94
+ if (entry := await self._client.get_entry(key)) is None:
95
+ return None
96
+
97
+ return DataCache.UrlData(check.non_empty_str(self._client.get_entry_url(entry)))
98
+
99
+ async def put_data(self, key: str, data: DataCache.Data) -> None:
100
+ await FileCacheDataCache(self).put_data(key, data)
omdev/ci/github/client.py CHANGED
@@ -13,9 +13,9 @@ from omlish.asyncs.asyncio.asyncio import asyncio_wait_concurrent
13
13
  from omlish.lite.check import check
14
14
  from omlish.lite.json import json_dumps_compact
15
15
  from omlish.lite.logs import log
16
+ from omlish.lite.timing import log_timing_context
16
17
 
17
18
  from ..consts import CI_CACHE_VERSION
18
- from ..utils import log_timing_context
19
19
  from .api import GithubCacheServiceV1
20
20
  from .env import register_github_env_var
21
21
 
@@ -31,6 +31,9 @@ class GithubCacheClient(abc.ABC):
31
31
  def get_entry(self, key: str) -> ta.Awaitable[ta.Optional[Entry]]:
32
32
  raise NotImplementedError
33
33
 
34
+ def get_entry_url(self, entry: Entry) -> ta.Optional[str]:
35
+ return None
36
+
34
37
  @abc.abstractmethod
35
38
  def download_file(self, entry: Entry, out_file: str) -> ta.Awaitable[None]:
36
39
  raise NotImplementedError
@@ -97,7 +100,7 @@ class GithubCacheServiceV1BaseClient(GithubCacheClient, abc.ABC):
97
100
  def _get_loop(self) -> asyncio.AbstractEventLoop:
98
101
  if (loop := self._given_loop) is not None:
99
102
  return loop
100
- return asyncio.get_event_loop()
103
+ return asyncio.get_running_loop()
101
104
 
102
105
  #
103
106
 
@@ -225,6 +228,10 @@ class GithubCacheServiceV1BaseClient(GithubCacheClient, abc.ABC):
225
228
  class Entry(GithubCacheClient.Entry):
226
229
  artifact: GithubCacheServiceV1.ArtifactCacheEntry
227
230
 
231
+ def get_entry_url(self, entry: GithubCacheClient.Entry) -> ta.Optional[str]:
232
+ entry1 = check.isinstance(entry, self.Entry)
233
+ return entry1.artifact.cache_key
234
+
228
235
  #
229
236
 
230
237
  def build_get_entry_url_path(self, *keys: str) -> str:
omdev/ci/github/inject.py CHANGED
@@ -6,7 +6,7 @@ from omlish.lite.inject import InjectorBindings
6
6
  from omlish.lite.inject import inj
7
7
 
8
8
  from ..cache import FileCache
9
- from .cache import GithubFileCache
9
+ from .cache import GithubCache
10
10
 
11
11
 
12
12
  ##
@@ -20,11 +20,11 @@ def bind_github(
20
20
 
21
21
  if cache_dir is not None:
22
22
  lst.extend([
23
- inj.bind(GithubFileCache.Config(
23
+ inj.bind(GithubCache.Config(
24
24
  dir=cache_dir,
25
25
  )),
26
- inj.bind(GithubFileCache, singleton=True),
27
- inj.bind(FileCache, to_key=GithubFileCache),
26
+ inj.bind(GithubCache, singleton=True),
27
+ inj.bind(FileCache, to_key=GithubCache),
28
28
  ])
29
29
 
30
30
  return inj.as_bindings(*lst)
omdev/ci/utils.py CHANGED
@@ -1,11 +1,7 @@
1
1
  # ruff: noqa: UP006 UP007
2
2
  import hashlib
3
- import logging
4
- import time
5
3
  import typing as ta
6
4
 
7
- from omlish.lite.logs import log
8
-
9
5
 
10
6
  ##
11
7
 
@@ -22,48 +18,3 @@ def read_yaml_file(yaml_file: str) -> ta.Any:
22
18
 
23
19
  def sha256_str(s: str) -> str:
24
20
  return hashlib.sha256(s.encode('utf-8')).hexdigest()
25
-
26
-
27
- ##
28
-
29
-
30
- class LogTimingContext:
31
- DEFAULT_LOG: ta.ClassVar[logging.Logger] = log
32
-
33
- def __init__(
34
- self,
35
- description: str,
36
- *,
37
- log: ta.Optional[logging.Logger] = None, # noqa
38
- level: int = logging.DEBUG,
39
- ) -> None:
40
- super().__init__()
41
-
42
- self._description = description
43
- self._log = log if log is not None else self.DEFAULT_LOG
44
- self._level = level
45
-
46
- def set_description(self, description: str) -> 'LogTimingContext':
47
- self._description = description
48
- return self
49
-
50
- _begin_time: float
51
- _end_time: float
52
-
53
- def __enter__(self) -> 'LogTimingContext':
54
- self._begin_time = time.time()
55
-
56
- self._log.log(self._level, f'Begin : {self._description}') # noqa
57
-
58
- return self
59
-
60
- def __exit__(self, exc_type, exc_val, exc_tb):
61
- self._end_time = time.time()
62
-
63
- self._log.log(
64
- self._level,
65
- f'End : {self._description} - {self._end_time - self._begin_time:0.2f} s elapsed',
66
- )
67
-
68
-
69
- log_timing_context = LogTimingContext
@@ -16,6 +16,8 @@ class DataServerTarget(abc.ABC): # noqa
16
16
  content_type: ta.Optional[str] = dc.field(default=None, metadata={OBJ_MARSHALER_OMIT_IF_NONE: True})
17
17
  content_length: ta.Optional[int] = dc.field(default=None, metadata={OBJ_MARSHALER_OMIT_IF_NONE: True})
18
18
 
19
+ #
20
+
19
21
  @classmethod
20
22
  def of(
21
23
  cls,
@@ -62,6 +64,36 @@ class DataServerTarget(abc.ABC): # noqa
62
64
  else:
63
65
  raise TypeError('No target type provided')
64
66
 
67
+ #
68
+
69
+ @classmethod
70
+ def of_bytes(cls, data: bytes) -> 'BytesDataServerTarget':
71
+ return BytesDataServerTarget(
72
+ data=data,
73
+ content_type='application/octet-stream',
74
+ )
75
+
76
+ @classmethod
77
+ def of_text(cls, data: str) -> 'BytesDataServerTarget':
78
+ return BytesDataServerTarget(
79
+ data=data.encode('utf-8'),
80
+ content_type='text/plain; charset=utf-8',
81
+ )
82
+
83
+ @classmethod
84
+ def of_json(cls, data: str) -> 'BytesDataServerTarget':
85
+ return BytesDataServerTarget(
86
+ data=data.encode('utf-8'),
87
+ content_type='application/json; charset=utf-8',
88
+ )
89
+
90
+ @classmethod
91
+ def of_html(cls, data: str) -> 'BytesDataServerTarget':
92
+ return BytesDataServerTarget(
93
+ data=data.encode('utf-8'),
94
+ content_type='text/html; charset=utf-8',
95
+ )
96
+
65
97
 
66
98
  @dc.dataclass(frozen=True)
67
99
  class BytesDataServerTarget(DataServerTarget):
omdev/oci/data.py CHANGED
@@ -5,6 +5,7 @@ import dataclasses as dc
5
5
  import enum
6
6
  import typing as ta
7
7
 
8
+ from omlish.lite.check import check
8
9
  from omlish.lite.marshal import OBJ_MARSHALER_FIELD_KEY
9
10
  from omlish.lite.marshal import OBJ_MARSHALER_OMIT_IF_NONE
10
11
 
@@ -149,3 +150,21 @@ def is_empty_oci_dataclass(obj: OciDataclass) -> bool:
149
150
 
150
151
  else:
151
152
  return False
153
+
154
+
155
+ ##
156
+
157
+
158
+ def get_single_leaf_oci_image_index(image_index: OciImageIndex) -> OciImageIndex:
159
+ while True:
160
+ child_manifest = check.single(image_index.manifests)
161
+ if isinstance(child_manifest, OciImageManifest):
162
+ break
163
+ image_index = check.isinstance(child_manifest, OciImageIndex)
164
+
165
+ return image_index
166
+
167
+
168
+ def get_single_oci_image_manifest(image_index: OciImageIndex) -> OciImageManifest:
169
+ child_index = check.single(image_index.manifests)
170
+ return check.isinstance(child_index, OciImageManifest)
omdev/oci/dataserver.py CHANGED
@@ -1,4 +1,4 @@
1
- # ruff: noqa: PT009 UP006 UP007
1
+ # ruff: noqa: UP006 UP007
2
2
  import typing as ta
3
3
 
4
4
  from omlish.lite.check import check
@@ -13,6 +13,9 @@ from .datarefs import open_oci_data_ref
13
13
  from .media import OCI_MANIFEST_MEDIA_TYPES
14
14
 
15
15
 
16
+ ##
17
+
18
+
16
19
  def build_oci_repository_data_server_routes(
17
20
  repo_name: str,
18
21
  built_repo: BuiltOciImageIndexRepository,
File without changes
@@ -0,0 +1,185 @@
1
+ # ruff: noqa: UP006 UP007
2
+ # @omlish-lite
3
+ import heapq
4
+ import tarfile
5
+ import typing as ta
6
+
7
+ from omlish.lite.cached import cached_nullary
8
+ from omlish.lite.check import check
9
+ from omlish.lite.contextmanagers import ExitStacked
10
+
11
+ from ..compression import OciCompression
12
+ from ..tars import OciDataTarWriter
13
+ from ..tars import WrittenOciDataTarFileInfo
14
+
15
+
16
+ ##
17
+
18
+
19
+ class OciLayerPacker(ExitStacked):
20
+ def __init__(
21
+ self,
22
+ input_file_path: str,
23
+ output_file_paths: ta.Sequence[str],
24
+ *,
25
+ compression: ta.Optional[OciCompression] = None,
26
+ ) -> None:
27
+ super().__init__()
28
+
29
+ self._input_file_path = input_file_path
30
+ self._output_file_paths = list(output_file_paths)
31
+ self._compression = compression
32
+
33
+ self._output_file_indexes_by_name: ta.Dict[str, int] = {}
34
+
35
+ #
36
+
37
+ @cached_nullary
38
+ def _input_tar_file(self) -> tarfile.TarFile:
39
+ # FIXME: check uncompressed
40
+ return self._enter_context(tarfile.open(self._input_file_path))
41
+
42
+ #
43
+
44
+ @cached_nullary
45
+ def _entries_by_name(self) -> ta.Mapping[str, tarfile.TarInfo]:
46
+ return {
47
+ info.name: info
48
+ for info in self._input_tar_file().getmembers()
49
+ }
50
+
51
+ #
52
+
53
+ class _CategorizedEntries(ta.NamedTuple):
54
+ files_by_name: ta.Mapping[str, tarfile.TarInfo]
55
+ non_files_by_name: ta.Mapping[str, tarfile.TarInfo]
56
+ links_by_name: ta.Mapping[str, tarfile.TarInfo]
57
+
58
+ @cached_nullary
59
+ def _categorized_entries(self) -> _CategorizedEntries:
60
+ files_by_name: ta.Dict[str, tarfile.TarInfo] = {}
61
+ non_files_by_name: ta.Dict[str, tarfile.TarInfo] = {}
62
+ links_by_name: ta.Dict[str, tarfile.TarInfo] = {}
63
+
64
+ for name, info in self._entries_by_name().items():
65
+ if info.type in tarfile.REGULAR_TYPES:
66
+ files_by_name[name] = info
67
+ elif info.type in (tarfile.LNKTYPE, tarfile.GNUTYPE_LONGLINK):
68
+ links_by_name[name] = info
69
+ else:
70
+ non_files_by_name[name] = info
71
+
72
+ return self._CategorizedEntries(
73
+ files_by_name=files_by_name,
74
+ non_files_by_name=non_files_by_name,
75
+ links_by_name=links_by_name,
76
+ )
77
+
78
+ #
79
+
80
+ @cached_nullary
81
+ def _non_files_sorted_by_name(self) -> ta.Sequence[tarfile.TarInfo]:
82
+ return sorted(
83
+ self._categorized_entries().non_files_by_name.values(),
84
+ key=lambda info: info.name,
85
+ )
86
+
87
+ @cached_nullary
88
+ def _files_descending_by_size(self) -> ta.Sequence[tarfile.TarInfo]:
89
+ return sorted(
90
+ self._categorized_entries().files_by_name.values(),
91
+ key=lambda info: -check.isinstance(info.size, int),
92
+ )
93
+
94
+ #
95
+
96
+ @cached_nullary
97
+ def _output_files(self) -> ta.Sequence[ta.BinaryIO]:
98
+ return [
99
+ self._enter_context(open(output_file_path, 'wb'))
100
+ for output_file_path in self._output_file_paths
101
+ ]
102
+
103
+ @cached_nullary
104
+ def _output_tar_writers(self) -> ta.Sequence[OciDataTarWriter]:
105
+ return [
106
+ self._enter_context(
107
+ OciDataTarWriter(
108
+ output_file,
109
+ compression=self._compression,
110
+ ),
111
+ )
112
+ for output_file in self._output_files()
113
+ ]
114
+
115
+ #
116
+
117
+ def _write_entry(
118
+ self,
119
+ info: tarfile.TarInfo,
120
+ output_file_idx: int,
121
+ ) -> None:
122
+ check.not_in(info.name, self._output_file_indexes_by_name)
123
+
124
+ writer = self._output_tar_writers()[output_file_idx]
125
+
126
+ if info.type in tarfile.REGULAR_TYPES:
127
+ with check.not_none(self._input_tar_file().extractfile(info)) as f:
128
+ writer.add_file(info, f) # type: ignore
129
+
130
+ else:
131
+ writer.add_file(info)
132
+
133
+ self._output_file_indexes_by_name[info.name] = output_file_idx
134
+
135
+ @cached_nullary
136
+ def _write_non_files(self) -> None:
137
+ for non_file in self._non_files_sorted_by_name():
138
+ self._write_entry(non_file, 0)
139
+
140
+ @cached_nullary
141
+ def _write_files(self) -> None:
142
+ writers = self._output_tar_writers()
143
+
144
+ bins = [
145
+ (writer.info().compressed_sz, i)
146
+ for i, writer in enumerate(writers)
147
+ ]
148
+
149
+ heapq.heapify(bins)
150
+
151
+ for file in self._files_descending_by_size():
152
+ _, bin_index = heapq.heappop(bins)
153
+
154
+ writer = writers[bin_index]
155
+
156
+ self._write_entry(file, bin_index)
157
+
158
+ bin_size = writer.info().compressed_sz
159
+
160
+ heapq.heappush(bins, (bin_size, bin_index))
161
+
162
+ @cached_nullary
163
+ def _write_links(self) -> None:
164
+ for link in self._categorized_entries().links_by_name.values():
165
+ link_name = check.non_empty_str(link.linkname)
166
+
167
+ output_file_idx = self._output_file_indexes_by_name[link_name]
168
+
169
+ self._write_entry(link, output_file_idx)
170
+
171
+ @cached_nullary
172
+ def write(self) -> ta.Mapping[str, WrittenOciDataTarFileInfo]:
173
+ writers = self._output_tar_writers()
174
+
175
+ self._write_non_files()
176
+ self._write_files()
177
+ self._write_links()
178
+
179
+ for output_tar_writer in writers:
180
+ output_tar_writer.tar_file().close()
181
+
182
+ return {
183
+ output_file_path: output_tar_writer.info()
184
+ for output_file_path, output_tar_writer in zip(self._output_file_paths, writers)
185
+ }
@@ -0,0 +1,162 @@
1
+ # ruff: noqa: UP006 UP007
2
+ # @omlish-lite
3
+ import copy
4
+ import os.path
5
+ import shutil
6
+ import typing as ta
7
+
8
+ from omlish.lite.cached import cached_nullary
9
+ from omlish.lite.check import check
10
+ from omlish.lite.contextmanagers import ExitStacked
11
+ from omlish.lite.timing import log_timing_context
12
+ from omlish.os.temp import temp_dir_context
13
+
14
+ from ...oci.tars import WrittenOciDataTarFileInfo
15
+ from ..building import BuiltOciImageIndexRepository
16
+ from ..building import build_oci_index_repository
17
+ from ..compression import OciCompression
18
+ from ..data import OciImageIndex
19
+ from ..data import OciImageLayer
20
+ from ..data import OciImageManifest
21
+ from ..data import get_single_leaf_oci_image_index
22
+ from ..data import get_single_oci_image_manifest
23
+ from ..datarefs import FileOciDataRef
24
+ from ..datarefs import open_oci_data_ref
25
+ from ..loading import read_oci_repository_root_index
26
+ from ..repositories import OciRepository
27
+ from .packing import OciLayerPacker
28
+ from .unpacking import OciLayerUnpacker
29
+
30
+
31
+ ##
32
+
33
+
34
+ class OciPackedRepositoryBuilder(ExitStacked):
35
+ def __init__(
36
+ self,
37
+ source_repo: OciRepository,
38
+ *,
39
+ temp_dir: ta.Optional[str] = None,
40
+
41
+ num_packed_files: int = 3, # GH actions have this set to 3, the default
42
+ packed_compression: ta.Optional[OciCompression] = OciCompression.ZSTD,
43
+ ) -> None:
44
+ super().__init__()
45
+
46
+ self._source_repo = source_repo
47
+
48
+ self._given_temp_dir = temp_dir
49
+
50
+ check.arg(num_packed_files > 0)
51
+ self._num_packed_files = num_packed_files
52
+
53
+ self._packed_compression = packed_compression
54
+
55
+ @cached_nullary
56
+ def _temp_dir(self) -> str:
57
+ if (given := self._given_temp_dir) is not None:
58
+ return given
59
+ else:
60
+ return self._enter_context(temp_dir_context()) # noqa
61
+
62
+ #
63
+
64
+ @cached_nullary
65
+ def _source_image_index(self) -> OciImageIndex:
66
+ image_index = read_oci_repository_root_index(self._source_repo)
67
+ return get_single_leaf_oci_image_index(image_index)
68
+
69
+ @cached_nullary
70
+ def _source_image_manifest(self) -> OciImageManifest:
71
+ return get_single_oci_image_manifest(self._source_image_index())
72
+
73
+ #
74
+
75
+ @cached_nullary
76
+ def _extracted_layer_tar_files(self) -> ta.List[str]:
77
+ image = self._source_image_manifest()
78
+
79
+ layer_tar_files = []
80
+
81
+ for i, layer in enumerate(image.layers):
82
+ if isinstance(layer.data, FileOciDataRef):
83
+ input_file_path = layer.data.path
84
+
85
+ else:
86
+ input_file_path = os.path.join(self._temp_dir(), f'save-layer-{i}.tar')
87
+ with open(input_file_path, 'wb') as input_file: # noqa
88
+ with open_oci_data_ref(layer.data) as layer_file:
89
+ shutil.copyfileobj(layer_file, input_file, length=1024 * 1024) # noqa
90
+
91
+ layer_tar_files.append(input_file_path)
92
+
93
+ return layer_tar_files
94
+
95
+ #
96
+
97
+ @cached_nullary
98
+ def _unpacked_tar_file(self) -> str:
99
+ layer_tar_files = self._extracted_layer_tar_files()
100
+ unpacked_file = os.path.join(self._temp_dir(), 'unpacked.tar')
101
+
102
+ with log_timing_context(f'Unpacking docker image {self._source_repo}'):
103
+ with OciLayerUnpacker(
104
+ layer_tar_files,
105
+ unpacked_file,
106
+ ) as lu:
107
+ lu.write()
108
+
109
+ return unpacked_file
110
+
111
+ #
112
+
113
+ @cached_nullary
114
+ def _packed_tar_files(self) -> ta.Mapping[str, WrittenOciDataTarFileInfo]:
115
+ unpacked_tar_file = self._unpacked_tar_file()
116
+
117
+ packed_tar_files = [
118
+ os.path.join(self._temp_dir(), f'packed-{i}.tar')
119
+ for i in range(self._num_packed_files)
120
+ ]
121
+
122
+ with log_timing_context(f'Packing docker image {self._source_repo}'):
123
+ with OciLayerPacker(
124
+ unpacked_tar_file,
125
+ packed_tar_files,
126
+ compression=self._packed_compression,
127
+ ) as lp:
128
+ return lp.write()
129
+
130
+ #
131
+
132
+ @cached_nullary
133
+ def _packed_image_index(self) -> OciImageIndex:
134
+ image_index = copy.deepcopy(self._source_image_index())
135
+
136
+ image = get_single_oci_image_manifest(image_index)
137
+
138
+ image.config.history = None
139
+
140
+ written = self._packed_tar_files()
141
+
142
+ # FIXME: use prebuilt sha256
143
+ image.layers = [
144
+ OciImageLayer(
145
+ kind=OciImageLayer.Kind.from_compression(self._packed_compression),
146
+ data=FileOciDataRef(output_file),
147
+ )
148
+ for output_file, output_file_info in written.items()
149
+ ]
150
+
151
+ image.config.rootfs.diff_ids = [
152
+ f'sha256:{output_file_info.tar_sha256}'
153
+ for output_file_info in written.values()
154
+ ]
155
+
156
+ return image_index
157
+
158
+ #
159
+
160
+ @cached_nullary
161
+ def build(self) -> BuiltOciImageIndexRepository:
162
+ return build_oci_index_repository(self._packed_image_index())