omdev 0.0.0.dev209__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/cli.py ADDED
@@ -0,0 +1,189 @@
1
+ # @omlish-amalg ../scripts/ci.py
2
+ # ruff: noqa: UP006 UP007
3
+ # @omlish-lite
4
+ """
5
+ Inputs:
6
+ - requirements.txt
7
+ - ci.Dockerfile
8
+ - compose.yml
9
+
10
+ ==
11
+
12
+ ./python -m ci run --cache-dir ci/cache ci/project omlish-ci
13
+ """
14
+ import asyncio
15
+ import os.path
16
+ import sys
17
+ import typing as ta
18
+
19
+ from omlish.argparse.cli import ArgparseCli
20
+ from omlish.argparse.cli import argparse_arg
21
+ from omlish.argparse.cli import argparse_cmd
22
+ from omlish.lite.check import check
23
+ from omlish.logs.standard import configure_standard_logging
24
+
25
+ from .cache import DirectoryFileCache
26
+ from .cache import DirectoryShellCache
27
+ from .cache import FileCache
28
+ from .cache import ShellCache
29
+ from .ci import Ci
30
+ from .compose import get_compose_service_dependencies
31
+ from .github.cache import GithubShellCache
32
+ from .github.cli import GithubCli
33
+ from .requirements import build_requirements_hash
34
+ from .shell import ShellCmd
35
+
36
+
37
+ class CiCli(ArgparseCli):
38
+ #
39
+
40
+ @argparse_cmd(
41
+ argparse_arg('requirements-txt', nargs='+'),
42
+ )
43
+ def print_requirements_hash(self) -> None:
44
+ requirements_txts = self.args.requirements_txt
45
+
46
+ print(build_requirements_hash(requirements_txts))
47
+
48
+ #
49
+
50
+ @argparse_cmd(
51
+ argparse_arg('compose-file'),
52
+ argparse_arg('service'),
53
+ )
54
+ def dump_compose_deps(self) -> None:
55
+ compose_file = self.args.compose_file
56
+ service = self.args.service
57
+
58
+ print(get_compose_service_dependencies(
59
+ compose_file,
60
+ service,
61
+ ))
62
+
63
+ #
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
+
73
+ @argparse_cmd(
74
+ argparse_arg('project-dir'),
75
+ argparse_arg('service'),
76
+ argparse_arg('--docker-file'),
77
+ argparse_arg('--compose-file'),
78
+ argparse_arg('-r', '--requirements-txt', action='append'),
79
+ argparse_arg('--github-cache', action='store_true'),
80
+ argparse_arg('--cache-dir'),
81
+ argparse_arg('--always-pull', action='store_true'),
82
+ )
83
+ async def run(self) -> None:
84
+ project_dir = self.args.project_dir
85
+ docker_file = self.args.docker_file
86
+ compose_file = self.args.compose_file
87
+ service = self.args.service
88
+ requirements_txts = self.args.requirements_txt
89
+ cache_dir = self.args.cache_dir
90
+ always_pull = self.args.always_pull
91
+
92
+ #
93
+
94
+ check.state(os.path.isdir(project_dir))
95
+
96
+ #
97
+
98
+ def find_alt_file(*alts: str) -> ta.Optional[str]:
99
+ for alt in alts:
100
+ alt_file = os.path.abspath(os.path.join(project_dir, alt))
101
+ if os.path.isfile(alt_file):
102
+ return alt_file
103
+ return None
104
+
105
+ if docker_file is None:
106
+ docker_file = find_alt_file(
107
+ 'docker/ci/Dockerfile',
108
+ 'docker/ci.Dockerfile',
109
+ 'ci.Dockerfile',
110
+ 'Dockerfile',
111
+ )
112
+ check.state(os.path.isfile(docker_file))
113
+
114
+ if compose_file is None:
115
+ compose_file = find_alt_file(
116
+ 'docker/compose.yml',
117
+ 'compose.yml',
118
+ )
119
+ check.state(os.path.isfile(compose_file))
120
+
121
+ if not requirements_txts:
122
+ requirements_txts = []
123
+ for rf in [
124
+ 'requirements.txt',
125
+ 'requirements-dev.txt',
126
+ 'requirements-ci.txt',
127
+ ]:
128
+ if os.path.exists(os.path.join(project_dir, rf)):
129
+ requirements_txts.append(rf)
130
+ else:
131
+ for rf in requirements_txts:
132
+ check.state(os.path.isfile(rf))
133
+
134
+ #
135
+
136
+ shell_cache: ta.Optional[ShellCache] = None
137
+ file_cache: ta.Optional[FileCache] = None
138
+ if cache_dir is not None:
139
+ if not os.path.exists(cache_dir):
140
+ os.makedirs(cache_dir)
141
+ check.state(os.path.isdir(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)
151
+
152
+ #
153
+
154
+ with Ci(
155
+ Ci.Config(
156
+ project_dir=project_dir,
157
+
158
+ docker_file=docker_file,
159
+
160
+ compose_file=compose_file,
161
+ service=service,
162
+
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,
171
+ ),
172
+ file_cache=file_cache,
173
+ shell_cache=shell_cache,
174
+ ) as ci:
175
+ ci.run()
176
+
177
+
178
+ async def _async_main() -> ta.Optional[int]:
179
+ return await CiCli().async_cli_run()
180
+
181
+
182
+ def _main() -> None:
183
+ configure_standard_logging('DEBUG')
184
+
185
+ sys.exit(rc if isinstance(rc := asyncio.run(_async_main()), int) else 0)
186
+
187
+
188
+ if __name__ == '__main__':
189
+ _main()
omdev/ci/compose.py ADDED
@@ -0,0 +1,214 @@
1
+ # ruff: noqa: UP006 UP007
2
+ # @omlish-lite
3
+ """
4
+ TODO:
5
+ - fix rmi - only when not referenced anymore
6
+ """
7
+ import contextlib
8
+ import dataclasses as dc
9
+ import itertools
10
+ import os.path
11
+ import shlex
12
+ import typing as ta
13
+
14
+ from omlish.lite.cached import cached_nullary
15
+ from omlish.lite.check import check
16
+ from omlish.lite.contextmanagers import ExitStacked
17
+ from omlish.lite.contextmanagers import defer
18
+ from omlish.lite.json import json_dumps_pretty
19
+ from omlish.subprocesses import subprocesses
20
+
21
+ from .shell import ShellCmd
22
+ from .utils import make_temp_file
23
+ from .utils import read_yaml_file
24
+
25
+
26
+ ##
27
+
28
+
29
+ def get_compose_service_dependencies(
30
+ compose_file: str,
31
+ service: str,
32
+ ) -> ta.Dict[str, str]:
33
+ compose_dct = read_yaml_file(compose_file)
34
+
35
+ services = compose_dct['services']
36
+ service_dct = services[service]
37
+
38
+ out = {}
39
+ for dep_service in service_dct.get('depends_on', []):
40
+ dep_service_dct = services[dep_service]
41
+ out[dep_service] = dep_service_dct['image']
42
+
43
+ return out
44
+
45
+
46
+ ##
47
+
48
+
49
+ class DockerComposeRun(ExitStacked):
50
+ @dc.dataclass(frozen=True)
51
+ class Config:
52
+ compose_file: str
53
+ service: str
54
+
55
+ image: str
56
+
57
+ cmd: ShellCmd
58
+
59
+ #
60
+
61
+ run_options: ta.Optional[ta.Sequence[str]] = None
62
+
63
+ cwd: ta.Optional[str] = None
64
+
65
+ #
66
+
67
+ no_dependency_cleanup: bool = False
68
+
69
+ #
70
+
71
+ def __post_init__(self) -> None:
72
+ check.not_isinstance(self.run_options, str)
73
+
74
+ def __init__(self, cfg: Config) -> None:
75
+ super().__init__()
76
+
77
+ self._cfg = cfg
78
+
79
+ self._subprocess_kwargs = {
80
+ **(dict(cwd=self._cfg.cwd) if self._cfg.cwd is not None else {}),
81
+ }
82
+
83
+ #
84
+
85
+ @property
86
+ def image_tag(self) -> str:
87
+ pfx = 'sha256:'
88
+ if (image := self._cfg.image).startswith(pfx):
89
+ image = image[len(pfx):]
90
+
91
+ return f'{self._cfg.service}:{image}'
92
+
93
+ @cached_nullary
94
+ def tag_image(self) -> str:
95
+ image_tag = self.image_tag
96
+
97
+ subprocesses.check_call(
98
+ 'docker',
99
+ 'tag',
100
+ self._cfg.image,
101
+ image_tag,
102
+ **self._subprocess_kwargs,
103
+ )
104
+
105
+ def delete_tag() -> None:
106
+ subprocesses.check_call(
107
+ 'docker',
108
+ 'rmi',
109
+ image_tag,
110
+ **self._subprocess_kwargs,
111
+ )
112
+
113
+ self._enter_context(defer(delete_tag)) # noqa
114
+
115
+ return image_tag
116
+
117
+ #
118
+
119
+ def _rewrite_compose_dct(self, in_dct: ta.Dict[str, ta.Any]) -> ta.Dict[str, ta.Any]:
120
+ out = dict(in_dct)
121
+
122
+ #
123
+
124
+ in_services = in_dct['services']
125
+ out['services'] = out_services = {}
126
+
127
+ #
128
+
129
+ in_service: dict = in_services[self._cfg.service]
130
+ out_services[self._cfg.service] = out_service = dict(in_service)
131
+
132
+ out_service['image'] = self.image_tag
133
+
134
+ for k in ['build', 'platform']:
135
+ if k in out_service:
136
+ del out_service[k]
137
+
138
+ out_service['links'] = [
139
+ f'{l}:{l}' if ':' not in l else l
140
+ for l in out_service.get('links', [])
141
+ ]
142
+
143
+ #
144
+
145
+ depends_on = in_service.get('depends_on', [])
146
+
147
+ for dep_service, in_dep_service_dct in list(in_services.items()):
148
+ if dep_service not in depends_on:
149
+ continue
150
+
151
+ out_dep_service: dict = dict(in_dep_service_dct)
152
+ out_services[dep_service] = out_dep_service
153
+
154
+ out_dep_service['ports'] = []
155
+
156
+ #
157
+
158
+ return out
159
+
160
+ @cached_nullary
161
+ def rewrite_compose_file(self) -> str:
162
+ in_dct = read_yaml_file(self._cfg.compose_file)
163
+
164
+ out_dct = self._rewrite_compose_dct(in_dct)
165
+
166
+ #
167
+
168
+ out_compose_file = make_temp_file()
169
+ self._enter_context(defer(lambda: os.unlink(out_compose_file))) # noqa
170
+
171
+ compose_json = json_dumps_pretty(out_dct)
172
+
173
+ with open(out_compose_file, 'w') as f:
174
+ f.write(compose_json)
175
+
176
+ return out_compose_file
177
+
178
+ #
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
+
188
+ def run(self) -> None:
189
+ self.tag_image()
190
+
191
+ compose_file = self.rewrite_compose_file()
192
+
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([
198
+ 'docker',
199
+ 'compose',
200
+ '-f', compose_file,
201
+ 'run',
202
+ '--rm',
203
+ *itertools.chain.from_iterable(['-e', k] for k in (self._cfg.cmd.env or [])),
204
+ *(self._cfg.run_options or []),
205
+ self._cfg.service,
206
+ 'sh', '-c', shlex.quote(self._cfg.cmd.s),
207
+ ])
208
+
209
+ run_cmd = dc.replace(self._cfg.cmd, s=sh_cmd)
210
+
211
+ run_cmd.run(
212
+ subprocesses.check_call,
213
+ **self._subprocess_kwargs,
214
+ )
omdev/ci/docker.py ADDED
@@ -0,0 +1,151 @@
1
+ # ruff: noqa: UP006 UP007
2
+ # @omlish-lite
3
+ """
4
+ TODO:
5
+ - some less stupid Dockerfile hash
6
+ - doesn't change too much though
7
+ """
8
+ import contextlib
9
+ import dataclasses as dc
10
+ import json
11
+ import os.path
12
+ import shlex
13
+ import tarfile
14
+ import typing as ta
15
+
16
+ from omlish.lite.check import check
17
+ from omlish.lite.contextmanagers import defer
18
+ from omlish.subprocesses import subprocesses
19
+
20
+ from .shell import ShellCmd
21
+ from .utils import make_temp_file
22
+ from .utils import sha256_str
23
+
24
+
25
+ ##
26
+
27
+
28
+ def build_docker_file_hash(docker_file: str) -> str:
29
+ with open(docker_file) as f:
30
+ contents = f.read()
31
+
32
+ return sha256_str(contents)
33
+
34
+
35
+ ##
36
+
37
+
38
+ def read_docker_tar_image_tag(tar_file: str) -> str:
39
+ with tarfile.open(tar_file) as tf:
40
+ with contextlib.closing(check.not_none(tf.extractfile('manifest.json'))) as mf:
41
+ m = mf.read()
42
+
43
+ manifests = json.loads(m.decode('utf-8'))
44
+ manifest = check.single(manifests)
45
+ tag = check.non_empty_str(check.single(manifest['RepoTags']))
46
+ return tag
47
+
48
+
49
+ def read_docker_tar_image_id(tar_file: str) -> str:
50
+ with tarfile.open(tar_file) as tf:
51
+ with contextlib.closing(check.not_none(tf.extractfile('index.json'))) as mf:
52
+ i = mf.read()
53
+
54
+ index = json.loads(i.decode('utf-8'))
55
+ manifest = check.single(index['manifests'])
56
+ image_id = check.non_empty_str(manifest['digest'])
57
+ return image_id
58
+
59
+
60
+ ##
61
+
62
+
63
+ def is_docker_image_present(image: str) -> bool:
64
+ out = subprocesses.check_output(
65
+ 'docker',
66
+ 'images',
67
+ '--format', 'json',
68
+ image,
69
+ )
70
+
71
+ out_s = out.decode('utf-8').strip()
72
+ if not out_s:
73
+ return False
74
+
75
+ json.loads(out_s) # noqa
76
+ return True
77
+
78
+
79
+ def pull_docker_image(
80
+ image: str,
81
+ ) -> None:
82
+ subprocesses.check_call(
83
+ 'docker',
84
+ 'pull',
85
+ image,
86
+ )
87
+
88
+
89
+ def build_docker_image(
90
+ docker_file: str,
91
+ *,
92
+ cwd: ta.Optional[str] = None,
93
+ ) -> str:
94
+ id_file = make_temp_file()
95
+ with defer(lambda: os.unlink(id_file)):
96
+ subprocesses.check_call(
97
+ 'docker',
98
+ 'build',
99
+ '-f', os.path.abspath(docker_file),
100
+ '--iidfile', id_file,
101
+ '--squash',
102
+ '.',
103
+ **(dict(cwd=cwd) if cwd is not None else {}),
104
+ )
105
+
106
+ with open(id_file) as f:
107
+ image_id = check.single(f.read().strip().splitlines()).strip()
108
+
109
+ return image_id
110
+
111
+
112
+ ##
113
+
114
+
115
+ def save_docker_tar_cmd(
116
+ image: str,
117
+ output_cmd: ShellCmd,
118
+ ) -> None:
119
+ cmd = dc.replace(output_cmd, s=f'docker save {image} | {output_cmd.s}')
120
+ cmd.run(subprocesses.check_call)
121
+
122
+
123
+ def save_docker_tar(
124
+ image: str,
125
+ tar_file: str,
126
+ ) -> None:
127
+ return save_docker_tar_cmd(
128
+ image,
129
+ ShellCmd(f'cat > {shlex.quote(tar_file)}'),
130
+ )
131
+
132
+
133
+ #
134
+
135
+
136
+ def load_docker_tar_cmd(
137
+ input_cmd: ShellCmd,
138
+ ) -> str:
139
+ cmd = dc.replace(input_cmd, s=f'{input_cmd.s} | docker load')
140
+
141
+ out = cmd.run(subprocesses.check_output).decode()
142
+
143
+ line = check.single(out.strip().splitlines())
144
+ loaded = line.partition(':')[2].strip()
145
+ return loaded
146
+
147
+
148
+ def load_docker_tar(
149
+ tar_file: str,
150
+ ) -> str:
151
+ return load_docker_tar_cmd(ShellCmd(f'cat {shlex.quote(tar_file)}'))
File without changes
@@ -0,0 +1,11 @@
1
+ # ruff: noqa: UP006 UP007
2
+ # @omlish-lite
3
+ """
4
+ sudo rm -rf \
5
+ /usr/local/.ghcup \
6
+ /opt/hostedtoolcache \
7
+
8
+ /usr/local/.ghcup 6.4G, 3391250 files
9
+ /opt/hostedtoolcache 8.0G, 14843980 files
10
+ /usr/local/lib/android 6.4G, 17251667 files
11
+ """