omdev 0.0.0.dev209__py3-none-any.whl → 0.0.0.dev211__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
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
+ """