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/.manifests.json +1 -1
- omdev/cc/cdeps.py +34 -1
- omdev/cc/cdeps.toml +19 -2
- omdev/cc/cli.py +13 -1
- omdev/ci/__init__.py +1 -0
- omdev/ci/cache.py +100 -121
- omdev/ci/ci.py +161 -136
- omdev/ci/cli.py +62 -30
- omdev/ci/compose.py +26 -62
- omdev/ci/consts.py +1 -0
- omdev/ci/docker.py +39 -22
- omdev/ci/github/{cacheapi.py → api.py} +1 -2
- omdev/ci/github/bootstrap.py +8 -1
- omdev/ci/github/cache.py +36 -320
- omdev/ci/github/cli.py +9 -5
- omdev/ci/github/client.py +492 -0
- omdev/ci/github/env.py +21 -0
- omdev/ci/requirements.py +0 -1
- omdev/ci/shell.py +0 -1
- omdev/ci/utils.py +2 -14
- omdev/git/shallow.py +1 -1
- omdev/scripts/ci.py +1602 -887
- omdev/scripts/interp.py +23 -0
- omdev/scripts/pyproject.py +23 -0
- omdev/tokens/tokenizert.py +1 -3
- omdev/tools/docker.py +6 -0
- {omdev-0.0.0.dev212.dist-info → omdev-0.0.0.dev214.dist-info}/METADATA +2 -2
- {omdev-0.0.0.dev212.dist-info → omdev-0.0.0.dev214.dist-info}/RECORD +32 -29
- {omdev-0.0.0.dev212.dist-info → omdev-0.0.0.dev214.dist-info}/LICENSE +0 -0
- {omdev-0.0.0.dev212.dist-info → omdev-0.0.0.dev214.dist-info}/WHEEL +0 -0
- {omdev-0.0.0.dev212.dist-info → omdev-0.0.0.dev214.dist-info}/entry_points.txt +0 -0
- {omdev-0.0.0.dev212.dist-info → omdev-0.0.0.dev214.dist-info}/top_level.txt +0 -0
omdev/scripts/ci.py
CHANGED
@@ -3,7 +3,7 @@
|
|
3
3
|
# @omlish-lite
|
4
4
|
# @omlish-script
|
5
5
|
# @omlish-amalg-output ../ci/cli.py
|
6
|
-
# ruff: noqa: N802 UP006 UP007 UP036
|
6
|
+
# ruff: noqa: N802 TC003 UP006 UP007 UP036
|
7
7
|
"""
|
8
8
|
Inputs:
|
9
9
|
- requirements.txt
|
@@ -12,17 +12,20 @@ Inputs:
|
|
12
12
|
|
13
13
|
==
|
14
14
|
|
15
|
-
./python -m ci run --cache-dir ci/cache ci/project omlish-ci
|
15
|
+
./python -m omdev.ci run --cache-dir omdev/ci/tests/cache omdev/ci/tests/project omlish-ci
|
16
16
|
"""
|
17
17
|
import abc
|
18
18
|
import argparse
|
19
19
|
import asyncio
|
20
|
+
import asyncio.base_subprocess
|
21
|
+
import asyncio.subprocess
|
20
22
|
import collections
|
21
23
|
import contextlib
|
22
24
|
import dataclasses as dc
|
23
25
|
import datetime
|
24
26
|
import functools
|
25
27
|
import hashlib
|
28
|
+
import http.client
|
26
29
|
import inspect
|
27
30
|
import itertools
|
28
31
|
import json
|
@@ -39,6 +42,8 @@ import threading
|
|
39
42
|
import time
|
40
43
|
import types
|
41
44
|
import typing as ta
|
45
|
+
import urllib.parse
|
46
|
+
import urllib.request
|
42
47
|
|
43
48
|
|
44
49
|
########################################
|
@@ -54,9 +59,12 @@ if sys.version_info < (3, 8):
|
|
54
59
|
# shell.py
|
55
60
|
T = ta.TypeVar('T')
|
56
61
|
|
57
|
-
# ../../omlish/
|
62
|
+
# ../../omlish/asyncs/asyncio/asyncio.py
|
58
63
|
CallableT = ta.TypeVar('CallableT', bound=ta.Callable)
|
59
64
|
|
65
|
+
# ../../omlish/asyncs/asyncio/timeouts.py
|
66
|
+
AwaitableT = ta.TypeVar('AwaitableT', bound=ta.Awaitable)
|
67
|
+
|
60
68
|
# ../../omlish/lite/check.py
|
61
69
|
SizedT = ta.TypeVar('SizedT', bound=ta.Sized)
|
62
70
|
CheckMessage = ta.Union[str, ta.Callable[..., ta.Optional[str]], None] # ta.TypeAlias
|
@@ -70,11 +78,40 @@ ArgparseCmdFn = ta.Callable[[], ta.Optional[int]] # ta.TypeAlias
|
|
70
78
|
|
71
79
|
# ../../omlish/lite/contextmanagers.py
|
72
80
|
ExitStackedT = ta.TypeVar('ExitStackedT', bound='ExitStacked')
|
81
|
+
AsyncExitStackedT = ta.TypeVar('AsyncExitStackedT', bound='AsyncExitStacked')
|
73
82
|
|
74
83
|
# ../../omlish/subprocesses.py
|
75
84
|
SubprocessChannelOption = ta.Literal['pipe', 'stdout', 'devnull'] # ta.TypeAlias
|
76
85
|
|
77
86
|
|
87
|
+
########################################
|
88
|
+
# ../consts.py
|
89
|
+
|
90
|
+
|
91
|
+
CI_CACHE_VERSION = 1
|
92
|
+
|
93
|
+
|
94
|
+
########################################
|
95
|
+
# ../github/env.py
|
96
|
+
|
97
|
+
|
98
|
+
@dc.dataclass(frozen=True)
|
99
|
+
class GithubEnvVar:
|
100
|
+
k: str
|
101
|
+
|
102
|
+
def __call__(self) -> ta.Optional[str]:
|
103
|
+
return os.environ.get(self.k)
|
104
|
+
|
105
|
+
|
106
|
+
GITHUB_ENV_VARS: ta.Set[GithubEnvVar] = set()
|
107
|
+
|
108
|
+
|
109
|
+
def register_github_env_var(k: str) -> GithubEnvVar:
|
110
|
+
ev = GithubEnvVar(k)
|
111
|
+
GITHUB_ENV_VARS.add(ev)
|
112
|
+
return ev
|
113
|
+
|
114
|
+
|
78
115
|
########################################
|
79
116
|
# ../shell.py
|
80
117
|
|
@@ -113,6 +150,84 @@ class ShellCmd:
|
|
113
150
|
)
|
114
151
|
|
115
152
|
|
153
|
+
########################################
|
154
|
+
# ../../../omlish/asyncs/asyncio/asyncio.py
|
155
|
+
|
156
|
+
|
157
|
+
def asyncio_once(fn: CallableT) -> CallableT:
|
158
|
+
future = None
|
159
|
+
|
160
|
+
@functools.wraps(fn)
|
161
|
+
async def inner(*args, **kwargs):
|
162
|
+
nonlocal future
|
163
|
+
if not future:
|
164
|
+
future = asyncio.create_task(fn(*args, **kwargs))
|
165
|
+
return await future
|
166
|
+
|
167
|
+
return ta.cast(CallableT, inner)
|
168
|
+
|
169
|
+
|
170
|
+
def drain_tasks(loop=None):
|
171
|
+
if loop is None:
|
172
|
+
loop = asyncio.get_running_loop()
|
173
|
+
|
174
|
+
while loop._ready or loop._scheduled: # noqa
|
175
|
+
loop._run_once() # noqa
|
176
|
+
|
177
|
+
|
178
|
+
@contextlib.contextmanager
|
179
|
+
def draining_asyncio_tasks() -> ta.Iterator[None]:
|
180
|
+
loop = asyncio.get_running_loop()
|
181
|
+
try:
|
182
|
+
yield
|
183
|
+
finally:
|
184
|
+
if loop is not None:
|
185
|
+
drain_tasks(loop) # noqa
|
186
|
+
|
187
|
+
|
188
|
+
async def asyncio_wait_concurrent(
|
189
|
+
coros: ta.Iterable[ta.Awaitable[T]],
|
190
|
+
concurrency: ta.Union[int, asyncio.Semaphore],
|
191
|
+
*,
|
192
|
+
return_when: ta.Any = asyncio.FIRST_EXCEPTION,
|
193
|
+
) -> ta.List[T]:
|
194
|
+
if isinstance(concurrency, asyncio.Semaphore):
|
195
|
+
semaphore = concurrency
|
196
|
+
elif isinstance(concurrency, int):
|
197
|
+
semaphore = asyncio.Semaphore(concurrency)
|
198
|
+
else:
|
199
|
+
raise TypeError(concurrency)
|
200
|
+
|
201
|
+
async def limited_task(coro):
|
202
|
+
async with semaphore:
|
203
|
+
return await coro
|
204
|
+
|
205
|
+
tasks = [asyncio.create_task(limited_task(coro)) for coro in coros]
|
206
|
+
done, pending = await asyncio.wait(tasks, return_when=return_when)
|
207
|
+
|
208
|
+
for task in pending:
|
209
|
+
task.cancel()
|
210
|
+
|
211
|
+
for task in done:
|
212
|
+
if task.exception():
|
213
|
+
raise task.exception() # type: ignore
|
214
|
+
|
215
|
+
return [task.result() for task in done]
|
216
|
+
|
217
|
+
|
218
|
+
########################################
|
219
|
+
# ../../../omlish/asyncs/asyncio/timeouts.py
|
220
|
+
|
221
|
+
|
222
|
+
def asyncio_maybe_timeout(
|
223
|
+
fut: AwaitableT,
|
224
|
+
timeout: ta.Optional[float] = None,
|
225
|
+
) -> AwaitableT:
|
226
|
+
if timeout is not None:
|
227
|
+
fut = asyncio.wait_for(fut, timeout) # type: ignore
|
228
|
+
return fut
|
229
|
+
|
230
|
+
|
116
231
|
########################################
|
117
232
|
# ../../../omlish/lite/cached.py
|
118
233
|
|
@@ -208,6 +323,17 @@ class Checks:
|
|
208
323
|
|
209
324
|
#
|
210
325
|
|
326
|
+
def register_on_raise_breakpoint_if_env_var_set(self, key: str) -> None:
|
327
|
+
import os
|
328
|
+
|
329
|
+
def on_raise(exc: Exception) -> None: # noqa
|
330
|
+
if key in os.environ:
|
331
|
+
breakpoint() # noqa
|
332
|
+
|
333
|
+
self.register_on_raise(on_raise)
|
334
|
+
|
335
|
+
#
|
336
|
+
|
211
337
|
def set_exception_factory(self, factory: CheckExceptionFactory) -> None:
|
212
338
|
self._exception_factory = factory
|
213
339
|
|
@@ -523,6 +649,18 @@ class Checks:
|
|
523
649
|
|
524
650
|
return v
|
525
651
|
|
652
|
+
def not_equal(self, v: T, o: ta.Any, msg: CheckMessage = None) -> T:
|
653
|
+
if o == v:
|
654
|
+
self._raise(
|
655
|
+
ValueError,
|
656
|
+
'Must not be equal',
|
657
|
+
msg,
|
658
|
+
Checks._ArgsKwargs(v, o),
|
659
|
+
render_fmt='%s == %s',
|
660
|
+
)
|
661
|
+
|
662
|
+
return v
|
663
|
+
|
526
664
|
def is_(self, v: T, o: ta.Any, msg: CheckMessage = None) -> T:
|
527
665
|
if o is not v:
|
528
666
|
self._raise(
|
@@ -956,170 +1094,183 @@ class ProxyLogHandler(ProxyLogFilterer, logging.Handler):
|
|
956
1094
|
|
957
1095
|
|
958
1096
|
########################################
|
959
|
-
#
|
1097
|
+
# ../../../omlish/os/files.py
|
960
1098
|
|
961
1099
|
|
962
|
-
|
1100
|
+
def touch(self, mode: int = 0o666, exist_ok: bool = True) -> None:
|
1101
|
+
if exist_ok:
|
1102
|
+
# First try to bump modification time
|
1103
|
+
# Implementation note: GNU touch uses the UTIME_NOW option of the utimensat() / futimens() functions.
|
1104
|
+
try:
|
1105
|
+
os.utime(self, None)
|
1106
|
+
except OSError:
|
1107
|
+
pass
|
1108
|
+
else:
|
1109
|
+
return
|
1110
|
+
flags = os.O_CREAT | os.O_WRONLY
|
1111
|
+
if not exist_ok:
|
1112
|
+
flags |= os.O_EXCL
|
1113
|
+
fd = os.open(self, flags, mode)
|
1114
|
+
os.close(fd)
|
963
1115
|
|
964
1116
|
|
965
|
-
|
966
|
-
|
967
|
-
|
968
|
-
|
969
|
-
|
1117
|
+
def unlink_if_exists(path: str) -> None:
|
1118
|
+
try:
|
1119
|
+
os.unlink(path)
|
1120
|
+
except FileNotFoundError:
|
1121
|
+
pass
|
970
1122
|
|
971
|
-
@abc.abstractmethod
|
972
|
-
def put_file(self, key: str, file_path: str) -> ta.Optional[str]:
|
973
|
-
raise NotImplementedError
|
974
1123
|
|
1124
|
+
@contextlib.contextmanager
|
1125
|
+
def unlinking_if_exists(path: str) -> ta.Iterator[None]:
|
1126
|
+
try:
|
1127
|
+
yield
|
1128
|
+
finally:
|
1129
|
+
unlink_if_exists(path)
|
975
1130
|
|
976
|
-
#
|
977
1131
|
|
1132
|
+
########################################
|
1133
|
+
# ../cache.py
|
978
1134
|
|
979
|
-
class DirectoryFileCache(FileCache):
|
980
|
-
def __init__(self, dir: str) -> None: # noqa
|
981
|
-
super().__init__()
|
982
1135
|
|
983
|
-
|
1136
|
+
##
|
984
1137
|
|
985
|
-
#
|
986
1138
|
|
987
|
-
|
1139
|
+
@abc.abstractmethod
|
1140
|
+
class FileCache(abc.ABC):
|
1141
|
+
def __init__(
|
988
1142
|
self,
|
989
|
-
key: str,
|
990
1143
|
*,
|
991
|
-
|
992
|
-
) ->
|
993
|
-
|
994
|
-
os.makedirs(self._dir, exist_ok=True)
|
995
|
-
return os.path.join(self._dir, key)
|
996
|
-
|
997
|
-
def format_incomplete_file(self, f: str) -> str:
|
998
|
-
return os.path.join(os.path.dirname(f), f'_{os.path.basename(f)}.incomplete')
|
999
|
-
|
1000
|
-
#
|
1001
|
-
|
1002
|
-
def get_file(self, key: str) -> ta.Optional[str]:
|
1003
|
-
cache_file_path = self.get_cache_file_path(key)
|
1004
|
-
if not os.path.exists(cache_file_path):
|
1005
|
-
return None
|
1006
|
-
return cache_file_path
|
1007
|
-
|
1008
|
-
def put_file(self, key: str, file_path: str) -> None:
|
1009
|
-
cache_file_path = self.get_cache_file_path(key, make_dirs=True)
|
1010
|
-
shutil.copyfile(file_path, cache_file_path)
|
1144
|
+
version: int = CI_CACHE_VERSION,
|
1145
|
+
) -> None:
|
1146
|
+
super().__init__()
|
1011
1147
|
|
1148
|
+
check.isinstance(version, int)
|
1149
|
+
check.arg(version >= 0)
|
1150
|
+
self._version = version
|
1012
1151
|
|
1013
|
-
|
1152
|
+
@property
|
1153
|
+
def version(self) -> int:
|
1154
|
+
return self._version
|
1014
1155
|
|
1156
|
+
#
|
1015
1157
|
|
1016
|
-
class ShellCache(abc.ABC):
|
1017
1158
|
@abc.abstractmethod
|
1018
|
-
def
|
1159
|
+
def get_file(self, key: str) -> ta.Awaitable[ta.Optional[str]]:
|
1019
1160
|
raise NotImplementedError
|
1020
1161
|
|
1021
|
-
|
1022
|
-
|
1023
|
-
|
1024
|
-
|
1025
|
-
|
1026
|
-
|
1027
|
-
|
1028
|
-
|
1029
|
-
|
1030
|
-
|
1031
|
-
#
|
1162
|
+
@abc.abstractmethod
|
1163
|
+
def put_file(
|
1164
|
+
self,
|
1165
|
+
key: str,
|
1166
|
+
file_path: str,
|
1167
|
+
*,
|
1168
|
+
steal: bool = False,
|
1169
|
+
) -> ta.Awaitable[str]:
|
1170
|
+
raise NotImplementedError
|
1032
1171
|
|
1033
|
-
@property
|
1034
|
-
@abc.abstractmethod
|
1035
|
-
def cmd(self) -> ShellCmd:
|
1036
|
-
raise NotImplementedError
|
1037
1172
|
|
1038
|
-
|
1173
|
+
#
|
1039
1174
|
|
1040
|
-
def __enter__(self):
|
1041
|
-
return self
|
1042
1175
|
|
1043
|
-
|
1044
|
-
|
1045
|
-
|
1046
|
-
|
1047
|
-
|
1176
|
+
class DirectoryFileCache(FileCache):
|
1177
|
+
def __init__(
|
1178
|
+
self,
|
1179
|
+
dir: str, # noqa
|
1180
|
+
*,
|
1181
|
+
no_create: bool = False,
|
1182
|
+
no_purge: bool = False,
|
1183
|
+
**kwargs: ta.Any,
|
1184
|
+
) -> None: # noqa
|
1185
|
+
super().__init__(**kwargs)
|
1048
1186
|
|
1049
|
-
|
1187
|
+
self._dir = dir
|
1188
|
+
self._no_create = no_create
|
1189
|
+
self._no_purge = no_purge
|
1050
1190
|
|
1051
|
-
|
1052
|
-
def _commit(self) -> None:
|
1053
|
-
raise NotImplementedError
|
1191
|
+
#
|
1054
1192
|
|
1055
|
-
|
1056
|
-
if self._state == 'committed':
|
1057
|
-
return
|
1058
|
-
elif self._state == 'open':
|
1059
|
-
self._commit()
|
1060
|
-
self._state = 'committed'
|
1061
|
-
else:
|
1062
|
-
raise RuntimeError(self._state)
|
1193
|
+
VERSION_FILE_NAME = '.ci-cache-version'
|
1063
1194
|
|
1064
|
-
|
1195
|
+
@cached_nullary
|
1196
|
+
def setup_dir(self) -> None:
|
1197
|
+
version_file = os.path.join(self._dir, self.VERSION_FILE_NAME)
|
1065
1198
|
|
1066
|
-
|
1067
|
-
|
1068
|
-
raise NotImplementedError
|
1199
|
+
if self._no_create:
|
1200
|
+
check.state(os.path.isdir(self._dir))
|
1069
1201
|
|
1070
|
-
|
1071
|
-
|
1072
|
-
|
1073
|
-
|
1074
|
-
|
1075
|
-
self._state = 'committed'
|
1076
|
-
else:
|
1077
|
-
raise RuntimeError(self._state)
|
1202
|
+
elif not os.path.isdir(self._dir):
|
1203
|
+
os.makedirs(self._dir)
|
1204
|
+
with open(version_file, 'w') as f:
|
1205
|
+
f.write(str(self._version))
|
1206
|
+
return
|
1078
1207
|
|
1079
|
-
|
1080
|
-
|
1081
|
-
raise NotImplementedError
|
1208
|
+
with open(version_file) as f:
|
1209
|
+
dir_version = int(f.read().strip())
|
1082
1210
|
|
1211
|
+
if dir_version == self._version:
|
1212
|
+
return
|
1083
1213
|
|
1084
|
-
|
1214
|
+
if self._no_purge:
|
1215
|
+
raise RuntimeError(f'{dir_version=} != {self._version=}')
|
1085
1216
|
|
1217
|
+
dirs = [n for n in sorted(os.listdir(self._dir)) if os.path.isdir(os.path.join(self._dir, n))]
|
1218
|
+
if dirs:
|
1219
|
+
raise RuntimeError(
|
1220
|
+
f'Refusing to remove stale cache dir {self._dir!r} '
|
1221
|
+
f'due to present directories: {", ".join(dirs)}',
|
1222
|
+
)
|
1086
1223
|
|
1087
|
-
|
1088
|
-
|
1089
|
-
|
1224
|
+
for n in sorted(os.listdir(self._dir)):
|
1225
|
+
if n.startswith('.'):
|
1226
|
+
continue
|
1227
|
+
fp = os.path.join(self._dir, n)
|
1228
|
+
check.state(os.path.isfile(fp))
|
1229
|
+
log.debug('Purging stale cache file: %s', fp)
|
1230
|
+
os.unlink(fp)
|
1090
1231
|
|
1091
|
-
|
1232
|
+
os.unlink(version_file)
|
1092
1233
|
|
1093
|
-
|
1094
|
-
|
1095
|
-
if f is None:
|
1096
|
-
return None
|
1097
|
-
return ShellCmd(f'cat {shlex.quote(f)}')
|
1234
|
+
with open(version_file, 'w') as f:
|
1235
|
+
f.write(str(self._version))
|
1098
1236
|
|
1099
|
-
|
1100
|
-
def __init__(self, tf: str, f: str) -> None:
|
1101
|
-
super().__init__()
|
1237
|
+
#
|
1102
1238
|
|
1103
|
-
|
1104
|
-
self
|
1239
|
+
def get_cache_file_path(
|
1240
|
+
self,
|
1241
|
+
key: str,
|
1242
|
+
) -> str:
|
1243
|
+
self.setup_dir()
|
1244
|
+
return os.path.join(self._dir, key)
|
1105
1245
|
|
1106
|
-
|
1107
|
-
|
1108
|
-
return ShellCmd(f'cat > {shlex.quote(self._tf)}')
|
1246
|
+
def format_incomplete_file(self, f: str) -> str:
|
1247
|
+
return os.path.join(os.path.dirname(f), f'_{os.path.basename(f)}.incomplete')
|
1109
1248
|
|
1110
|
-
|
1111
|
-
os.replace(self._tf, self._f)
|
1249
|
+
#
|
1112
1250
|
|
1113
|
-
|
1114
|
-
|
1251
|
+
async def get_file(self, key: str) -> ta.Optional[str]:
|
1252
|
+
cache_file_path = self.get_cache_file_path(key)
|
1253
|
+
if not os.path.exists(cache_file_path):
|
1254
|
+
return None
|
1255
|
+
return cache_file_path
|
1115
1256
|
|
1116
|
-
def
|
1117
|
-
|
1118
|
-
|
1257
|
+
async def put_file(
|
1258
|
+
self,
|
1259
|
+
key: str,
|
1260
|
+
file_path: str,
|
1261
|
+
*,
|
1262
|
+
steal: bool = False,
|
1263
|
+
) -> str:
|
1264
|
+
cache_file_path = self.get_cache_file_path(key)
|
1265
|
+
if steal:
|
1266
|
+
shutil.move(file_path, cache_file_path)
|
1267
|
+
else:
|
1268
|
+
shutil.copyfile(file_path, cache_file_path)
|
1269
|
+
return cache_file_path
|
1119
1270
|
|
1120
1271
|
|
1121
1272
|
########################################
|
1122
|
-
# ../github/
|
1273
|
+
# ../github/api.py
|
1123
1274
|
"""
|
1124
1275
|
export FILE_SIZE=$(stat --format="%s" $FILE)
|
1125
1276
|
|
@@ -1205,7 +1356,7 @@ class GithubCacheServiceV1:
|
|
1205
1356
|
@dc.dataclass(frozen=True)
|
1206
1357
|
class ReserveCacheRequest:
|
1207
1358
|
key: str
|
1208
|
-
cache_size: ta.Optional[int]
|
1359
|
+
cache_size: ta.Optional[int] = None
|
1209
1360
|
version: ta.Optional[str] = None
|
1210
1361
|
|
1211
1362
|
@dc.dataclass(frozen=True)
|
@@ -1320,16 +1471,27 @@ class GithubCacheServiceV2:
|
|
1320
1471
|
|
1321
1472
|
|
1322
1473
|
########################################
|
1323
|
-
# ../
|
1474
|
+
# ../github/bootstrap.py
|
1475
|
+
"""
|
1476
|
+
sudo rm -rf \
|
1477
|
+
/usr/local/.ghcup \
|
1478
|
+
/opt/hostedtoolcache \
|
1324
1479
|
|
1480
|
+
/usr/local/.ghcup 6.4G, 3391250 files
|
1481
|
+
/opt/hostedtoolcache 8.0G, 14843980 files
|
1482
|
+
/usr/local/lib/android 6.4G, 17251667 files
|
1483
|
+
"""
|
1325
1484
|
|
1326
|
-
##
|
1327
1485
|
|
1486
|
+
GITHUB_ACTIONS_ENV_VAR = register_github_env_var('GITHUB_ACTIONS')
|
1328
1487
|
|
1329
|
-
|
1330
|
-
|
1331
|
-
|
1332
|
-
|
1488
|
+
|
1489
|
+
def is_in_github_actions() -> bool:
|
1490
|
+
return GITHUB_ACTIONS_ENV_VAR() is not None
|
1491
|
+
|
1492
|
+
|
1493
|
+
########################################
|
1494
|
+
# ../utils.py
|
1333
1495
|
|
1334
1496
|
|
1335
1497
|
##
|
@@ -1378,7 +1540,7 @@ class LogTimingContext:
|
|
1378
1540
|
def __enter__(self) -> 'LogTimingContext':
|
1379
1541
|
self._begin_time = time.time()
|
1380
1542
|
|
1381
|
-
self._log.log(self._level, f'Begin {self._description}') # noqa
|
1543
|
+
self._log.log(self._level, f'Begin : {self._description}') # noqa
|
1382
1544
|
|
1383
1545
|
return self
|
1384
1546
|
|
@@ -1387,7 +1549,7 @@ class LogTimingContext:
|
|
1387
1549
|
|
1388
1550
|
self._log.log(
|
1389
1551
|
self._level,
|
1390
|
-
f'End {self._description} - {self._end_time - self._begin_time:0.2f} s elapsed',
|
1552
|
+
f'End : {self._description} - {self._end_time - self._begin_time:0.2f} s elapsed',
|
1391
1553
|
)
|
1392
1554
|
|
1393
1555
|
|
@@ -1713,6 +1875,33 @@ class ExitStacked:
|
|
1713
1875
|
return es.enter_context(cm)
|
1714
1876
|
|
1715
1877
|
|
1878
|
+
class AsyncExitStacked:
|
1879
|
+
_exit_stack: ta.Optional[contextlib.AsyncExitStack] = None
|
1880
|
+
|
1881
|
+
async def __aenter__(self: AsyncExitStackedT) -> AsyncExitStackedT:
|
1882
|
+
check.state(self._exit_stack is None)
|
1883
|
+
es = self._exit_stack = contextlib.AsyncExitStack()
|
1884
|
+
await es.__aenter__()
|
1885
|
+
return self
|
1886
|
+
|
1887
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
1888
|
+
if (es := self._exit_stack) is None:
|
1889
|
+
return None
|
1890
|
+
await self._async_exit_contexts()
|
1891
|
+
return await es.__aexit__(exc_type, exc_val, exc_tb)
|
1892
|
+
|
1893
|
+
async def _async_exit_contexts(self) -> None:
|
1894
|
+
pass
|
1895
|
+
|
1896
|
+
def _enter_context(self, cm: ta.ContextManager[T]) -> T:
|
1897
|
+
es = check.not_none(self._exit_stack)
|
1898
|
+
return es.enter_context(cm)
|
1899
|
+
|
1900
|
+
async def _enter_async_context(self, cm: ta.AsyncContextManager[T]) -> T:
|
1901
|
+
es = check.not_none(self._exit_stack)
|
1902
|
+
return await es.enter_async_context(cm)
|
1903
|
+
|
1904
|
+
|
1716
1905
|
##
|
1717
1906
|
|
1718
1907
|
|
@@ -1724,6 +1913,17 @@ def defer(fn: ta.Callable) -> ta.Generator[ta.Callable, None, None]:
|
|
1724
1913
|
fn()
|
1725
1914
|
|
1726
1915
|
|
1916
|
+
@contextlib.asynccontextmanager
|
1917
|
+
async def adefer(fn: ta.Callable) -> ta.AsyncGenerator[ta.Callable, None]:
|
1918
|
+
try:
|
1919
|
+
yield fn
|
1920
|
+
finally:
|
1921
|
+
await fn()
|
1922
|
+
|
1923
|
+
|
1924
|
+
##
|
1925
|
+
|
1926
|
+
|
1727
1927
|
@contextlib.contextmanager
|
1728
1928
|
def attr_setting(obj, attr, val, *, default=None): # noqa
|
1729
1929
|
not_set = object()
|
@@ -1827,55 +2027,576 @@ class JsonLogFormatter(logging.Formatter):
|
|
1827
2027
|
|
1828
2028
|
|
1829
2029
|
########################################
|
1830
|
-
# ../../../omlish/
|
1831
|
-
"""
|
1832
|
-
TODO:
|
1833
|
-
- structured
|
1834
|
-
- prefixed
|
1835
|
-
- debug
|
1836
|
-
- optional noisy? noisy will never be lite - some kinda configure_standard callback mechanism?
|
1837
|
-
"""
|
2030
|
+
# ../../../omlish/os/temp.py
|
1838
2031
|
|
1839
2032
|
|
1840
|
-
|
2033
|
+
def make_temp_file(**kwargs: ta.Any) -> str:
|
2034
|
+
file_fd, file = tempfile.mkstemp(**kwargs)
|
2035
|
+
os.close(file_fd)
|
2036
|
+
return file
|
1841
2037
|
|
1842
2038
|
|
1843
|
-
|
1844
|
-
|
1845
|
-
|
1846
|
-
|
1847
|
-
|
1848
|
-
|
1849
|
-
|
1850
|
-
('message', '%(message)s'),
|
1851
|
-
]
|
2039
|
+
@contextlib.contextmanager
|
2040
|
+
def temp_file_context(**kwargs: ta.Any) -> ta.Iterator[str]:
|
2041
|
+
path = make_temp_file(**kwargs)
|
2042
|
+
try:
|
2043
|
+
yield path
|
2044
|
+
finally:
|
2045
|
+
unlink_if_exists(path)
|
1852
2046
|
|
1853
2047
|
|
1854
|
-
|
1855
|
-
|
1856
|
-
|
1857
|
-
|
2048
|
+
@contextlib.contextmanager
|
2049
|
+
def temp_dir_context(
|
2050
|
+
root_dir: ta.Optional[str] = None,
|
2051
|
+
**kwargs: ta.Any,
|
2052
|
+
) -> ta.Iterator[str]:
|
2053
|
+
path = tempfile.mkdtemp(dir=root_dir, **kwargs)
|
2054
|
+
try:
|
2055
|
+
yield path
|
2056
|
+
finally:
|
2057
|
+
shutil.rmtree(path, ignore_errors=True)
|
1858
2058
|
|
1859
|
-
converter = datetime.datetime.fromtimestamp # type: ignore
|
1860
2059
|
|
1861
|
-
|
1862
|
-
|
1863
|
-
|
1864
|
-
|
1865
|
-
|
1866
|
-
|
1867
|
-
|
2060
|
+
@contextlib.contextmanager
|
2061
|
+
def temp_named_file_context(
|
2062
|
+
root_dir: ta.Optional[str] = None,
|
2063
|
+
cleanup: bool = True,
|
2064
|
+
**kwargs: ta.Any,
|
2065
|
+
) -> ta.Iterator[tempfile._TemporaryFileWrapper]: # noqa
|
2066
|
+
with tempfile.NamedTemporaryFile(dir=root_dir, delete=False, **kwargs) as f:
|
2067
|
+
try:
|
2068
|
+
yield f
|
2069
|
+
finally:
|
2070
|
+
if cleanup:
|
2071
|
+
shutil.rmtree(f.name, ignore_errors=True)
|
2072
|
+
|
2073
|
+
|
2074
|
+
########################################
|
2075
|
+
# ../github/client.py
|
1868
2076
|
|
1869
2077
|
|
1870
2078
|
##
|
1871
2079
|
|
1872
2080
|
|
1873
|
-
class
|
1874
|
-
|
1875
|
-
|
2081
|
+
class GithubCacheClient(abc.ABC):
|
2082
|
+
class Entry(abc.ABC): # noqa
|
2083
|
+
pass
|
1876
2084
|
|
2085
|
+
@abc.abstractmethod
|
2086
|
+
def get_entry(self, key: str) -> ta.Awaitable[ta.Optional[Entry]]:
|
2087
|
+
raise NotImplementedError
|
1877
2088
|
|
1878
|
-
|
2089
|
+
@abc.abstractmethod
|
2090
|
+
def download_file(self, entry: Entry, out_file: str) -> ta.Awaitable[None]:
|
2091
|
+
raise NotImplementedError
|
2092
|
+
|
2093
|
+
@abc.abstractmethod
|
2094
|
+
def upload_file(self, key: str, in_file: str) -> ta.Awaitable[None]:
|
2095
|
+
raise NotImplementedError
|
2096
|
+
|
2097
|
+
|
2098
|
+
##
|
2099
|
+
|
2100
|
+
|
2101
|
+
class GithubCacheServiceV1BaseClient(GithubCacheClient, abc.ABC):
|
2102
|
+
BASE_URL_ENV_VAR = register_github_env_var('ACTIONS_CACHE_URL')
|
2103
|
+
AUTH_TOKEN_ENV_VAR = register_github_env_var('ACTIONS_RUNTIME_TOKEN') # noqa
|
2104
|
+
|
2105
|
+
KEY_SUFFIX_ENV_VAR = register_github_env_var('GITHUB_RUN_ID')
|
2106
|
+
|
2107
|
+
#
|
2108
|
+
|
2109
|
+
def __init__(
|
2110
|
+
self,
|
2111
|
+
*,
|
2112
|
+
base_url: ta.Optional[str] = None,
|
2113
|
+
auth_token: ta.Optional[str] = None,
|
2114
|
+
|
2115
|
+
key_prefix: ta.Optional[str] = None,
|
2116
|
+
key_suffix: ta.Optional[str] = None,
|
2117
|
+
|
2118
|
+
cache_version: int = CI_CACHE_VERSION,
|
2119
|
+
|
2120
|
+
loop: ta.Optional[asyncio.AbstractEventLoop] = None,
|
2121
|
+
) -> None:
|
2122
|
+
super().__init__()
|
2123
|
+
|
2124
|
+
#
|
2125
|
+
|
2126
|
+
if base_url is None:
|
2127
|
+
base_url = check.non_empty_str(self.BASE_URL_ENV_VAR())
|
2128
|
+
self._service_url = GithubCacheServiceV1.get_service_url(base_url)
|
2129
|
+
|
2130
|
+
if auth_token is None:
|
2131
|
+
auth_token = self.AUTH_TOKEN_ENV_VAR()
|
2132
|
+
self._auth_token = auth_token
|
2133
|
+
|
2134
|
+
#
|
2135
|
+
|
2136
|
+
self._key_prefix = key_prefix
|
2137
|
+
|
2138
|
+
if key_suffix is None:
|
2139
|
+
key_suffix = self.KEY_SUFFIX_ENV_VAR()
|
2140
|
+
self._key_suffix = check.non_empty_str(key_suffix)
|
2141
|
+
|
2142
|
+
#
|
2143
|
+
|
2144
|
+
self._cache_version = check.isinstance(cache_version, int)
|
2145
|
+
|
2146
|
+
#
|
2147
|
+
|
2148
|
+
self._given_loop = loop
|
2149
|
+
|
2150
|
+
#
|
2151
|
+
|
2152
|
+
def _get_loop(self) -> asyncio.AbstractEventLoop:
|
2153
|
+
if (loop := self._given_loop) is not None:
|
2154
|
+
return loop
|
2155
|
+
return asyncio.get_event_loop()
|
2156
|
+
|
2157
|
+
#
|
2158
|
+
|
2159
|
+
def build_request_headers(
|
2160
|
+
self,
|
2161
|
+
headers: ta.Optional[ta.Mapping[str, str]] = None,
|
2162
|
+
*,
|
2163
|
+
content_type: ta.Optional[str] = None,
|
2164
|
+
json_content: bool = False,
|
2165
|
+
) -> ta.Dict[str, str]:
|
2166
|
+
dct = {
|
2167
|
+
'Accept': ';'.join([
|
2168
|
+
'application/json',
|
2169
|
+
f'api-version={GithubCacheServiceV1.API_VERSION}',
|
2170
|
+
]),
|
2171
|
+
}
|
2172
|
+
|
2173
|
+
if (auth_token := self._auth_token):
|
2174
|
+
dct['Authorization'] = f'Bearer {auth_token}'
|
2175
|
+
|
2176
|
+
if content_type is None and json_content:
|
2177
|
+
content_type = 'application/json'
|
2178
|
+
if content_type is not None:
|
2179
|
+
dct['Content-Type'] = content_type
|
2180
|
+
|
2181
|
+
if headers:
|
2182
|
+
dct.update(headers)
|
2183
|
+
|
2184
|
+
return dct
|
2185
|
+
|
2186
|
+
#
|
2187
|
+
|
2188
|
+
def load_json_bytes(self, b: ta.Optional[bytes]) -> ta.Optional[ta.Any]:
|
2189
|
+
if not b:
|
2190
|
+
return None
|
2191
|
+
return json.loads(b.decode('utf-8-sig'))
|
2192
|
+
|
2193
|
+
#
|
2194
|
+
|
2195
|
+
async def send_url_request(
|
2196
|
+
self,
|
2197
|
+
req: urllib.request.Request,
|
2198
|
+
) -> ta.Tuple[http.client.HTTPResponse, ta.Optional[bytes]]:
|
2199
|
+
def run_sync():
|
2200
|
+
with urllib.request.urlopen(req) as resp: # noqa
|
2201
|
+
body = resp.read()
|
2202
|
+
return (resp, body)
|
2203
|
+
|
2204
|
+
return await self._get_loop().run_in_executor(None, run_sync) # noqa
|
2205
|
+
|
2206
|
+
#
|
2207
|
+
|
2208
|
+
@dc.dataclass()
|
2209
|
+
class ServiceRequestError(RuntimeError):
|
2210
|
+
status_code: int
|
2211
|
+
body: ta.Optional[bytes]
|
2212
|
+
|
2213
|
+
def __str__(self) -> str:
|
2214
|
+
return repr(self)
|
2215
|
+
|
2216
|
+
async def send_service_request(
|
2217
|
+
self,
|
2218
|
+
path: str,
|
2219
|
+
*,
|
2220
|
+
method: ta.Optional[str] = None,
|
2221
|
+
headers: ta.Optional[ta.Mapping[str, str]] = None,
|
2222
|
+
content_type: ta.Optional[str] = None,
|
2223
|
+
content: ta.Optional[bytes] = None,
|
2224
|
+
json_content: ta.Optional[ta.Any] = None,
|
2225
|
+
success_status_codes: ta.Optional[ta.Container[int]] = None,
|
2226
|
+
) -> ta.Optional[ta.Any]:
|
2227
|
+
url = f'{self._service_url}/{path}'
|
2228
|
+
|
2229
|
+
if content is not None and json_content is not None:
|
2230
|
+
raise RuntimeError('Must not pass both content and json_content')
|
2231
|
+
elif json_content is not None:
|
2232
|
+
content = json_dumps_compact(json_content).encode('utf-8')
|
2233
|
+
header_json_content = True
|
2234
|
+
else:
|
2235
|
+
header_json_content = False
|
2236
|
+
|
2237
|
+
if method is None:
|
2238
|
+
method = 'POST' if content is not None else 'GET'
|
2239
|
+
|
2240
|
+
#
|
2241
|
+
|
2242
|
+
req = urllib.request.Request( # noqa
|
2243
|
+
url,
|
2244
|
+
method=method,
|
2245
|
+
headers=self.build_request_headers(
|
2246
|
+
headers,
|
2247
|
+
content_type=content_type,
|
2248
|
+
json_content=header_json_content,
|
2249
|
+
),
|
2250
|
+
data=content,
|
2251
|
+
)
|
2252
|
+
|
2253
|
+
resp, body = await self.send_url_request(req)
|
2254
|
+
|
2255
|
+
#
|
2256
|
+
|
2257
|
+
if success_status_codes is not None:
|
2258
|
+
is_success = resp.status in success_status_codes
|
2259
|
+
else:
|
2260
|
+
is_success = (200 <= resp.status <= 300)
|
2261
|
+
if not is_success:
|
2262
|
+
raise self.ServiceRequestError(resp.status, body)
|
2263
|
+
|
2264
|
+
return self.load_json_bytes(body)
|
2265
|
+
|
2266
|
+
#
|
2267
|
+
|
2268
|
+
KEY_PART_SEPARATOR = '--'
|
2269
|
+
|
2270
|
+
def fix_key(self, s: str, partial_suffix: bool = False) -> str:
|
2271
|
+
return self.KEY_PART_SEPARATOR.join([
|
2272
|
+
*([self._key_prefix] if self._key_prefix else []),
|
2273
|
+
s,
|
2274
|
+
('' if partial_suffix else self._key_suffix),
|
2275
|
+
])
|
2276
|
+
|
2277
|
+
#
|
2278
|
+
|
2279
|
+
@dc.dataclass(frozen=True)
|
2280
|
+
class Entry(GithubCacheClient.Entry):
|
2281
|
+
artifact: GithubCacheServiceV1.ArtifactCacheEntry
|
2282
|
+
|
2283
|
+
#
|
2284
|
+
|
2285
|
+
def build_get_entry_url_path(self, *keys: str) -> str:
|
2286
|
+
qp = dict(
|
2287
|
+
keys=','.join(urllib.parse.quote_plus(k) for k in keys),
|
2288
|
+
version=str(self._cache_version),
|
2289
|
+
)
|
2290
|
+
|
2291
|
+
return '?'.join([
|
2292
|
+
'cache',
|
2293
|
+
'&'.join([
|
2294
|
+
f'{k}={v}'
|
2295
|
+
for k, v in qp.items()
|
2296
|
+
]),
|
2297
|
+
])
|
2298
|
+
|
2299
|
+
GET_ENTRY_SUCCESS_STATUS_CODES = (200, 204)
|
2300
|
+
|
2301
|
+
|
2302
|
+
##
|
2303
|
+
|
2304
|
+
|
2305
|
+
class GithubCacheServiceV1Client(GithubCacheServiceV1BaseClient):
|
2306
|
+
DEFAULT_CONCURRENCY = 4
|
2307
|
+
|
2308
|
+
DEFAULT_CHUNK_SIZE = 32 * 1024 * 1024
|
2309
|
+
|
2310
|
+
def __init__(
|
2311
|
+
self,
|
2312
|
+
*,
|
2313
|
+
concurrency: int = DEFAULT_CONCURRENCY,
|
2314
|
+
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
2315
|
+
**kwargs: ta.Any,
|
2316
|
+
) -> None:
|
2317
|
+
super().__init__(**kwargs)
|
2318
|
+
|
2319
|
+
check.arg(concurrency > 0)
|
2320
|
+
self._concurrency = concurrency
|
2321
|
+
|
2322
|
+
check.arg(chunk_size > 0)
|
2323
|
+
self._chunk_size = chunk_size
|
2324
|
+
|
2325
|
+
#
|
2326
|
+
|
2327
|
+
async def get_entry(self, key: str) -> ta.Optional[GithubCacheServiceV1BaseClient.Entry]:
|
2328
|
+
obj = await self.send_service_request(
|
2329
|
+
self.build_get_entry_url_path(self.fix_key(key, partial_suffix=True)),
|
2330
|
+
)
|
2331
|
+
if obj is None:
|
2332
|
+
return None
|
2333
|
+
|
2334
|
+
return self.Entry(GithubCacheServiceV1.dataclass_from_json(
|
2335
|
+
GithubCacheServiceV1.ArtifactCacheEntry,
|
2336
|
+
obj,
|
2337
|
+
))
|
2338
|
+
|
2339
|
+
#
|
2340
|
+
|
2341
|
+
@dc.dataclass(frozen=True)
|
2342
|
+
class _DownloadChunk:
|
2343
|
+
key: str
|
2344
|
+
url: str
|
2345
|
+
out_file: str
|
2346
|
+
offset: int
|
2347
|
+
size: int
|
2348
|
+
|
2349
|
+
async def _download_file_chunk_urllib(self, chunk: _DownloadChunk) -> None:
|
2350
|
+
req = urllib.request.Request( # noqa
|
2351
|
+
chunk.url,
|
2352
|
+
headers={
|
2353
|
+
'Range': f'bytes={chunk.offset}-{chunk.offset + chunk.size - 1}',
|
2354
|
+
},
|
2355
|
+
)
|
2356
|
+
|
2357
|
+
_, buf_ = await self.send_url_request(req)
|
2358
|
+
|
2359
|
+
buf = check.not_none(buf_)
|
2360
|
+
check.equal(len(buf), chunk.size)
|
2361
|
+
|
2362
|
+
#
|
2363
|
+
|
2364
|
+
def write_sync():
|
2365
|
+
with open(chunk.out_file, 'r+b') as f: # noqa
|
2366
|
+
f.seek(chunk.offset, os.SEEK_SET)
|
2367
|
+
f.write(buf)
|
2368
|
+
|
2369
|
+
await self._get_loop().run_in_executor(None, write_sync) # noqa
|
2370
|
+
|
2371
|
+
# async def _download_file_chunk_curl(self, chunk: _DownloadChunk) -> None:
|
2372
|
+
# async with contextlib.AsyncExitStack() as es:
|
2373
|
+
# f = open(chunk.out_file, 'r+b')
|
2374
|
+
# f.seek(chunk.offset, os.SEEK_SET)
|
2375
|
+
#
|
2376
|
+
# tmp_file = es.enter_context(temp_file_context()) # noqa
|
2377
|
+
#
|
2378
|
+
# proc = await es.enter_async_context(asyncio_subprocesses.popen(
|
2379
|
+
# 'curl',
|
2380
|
+
# '-s',
|
2381
|
+
# '-w', '%{json}',
|
2382
|
+
# '-H', f'Range: bytes={chunk.offset}-{chunk.offset + chunk.size - 1}',
|
2383
|
+
# chunk.url,
|
2384
|
+
# output=subprocess.PIPE,
|
2385
|
+
# ))
|
2386
|
+
#
|
2387
|
+
# futs = asyncio.gather(
|
2388
|
+
#
|
2389
|
+
# )
|
2390
|
+
#
|
2391
|
+
# await proc.wait()
|
2392
|
+
#
|
2393
|
+
# with open(tmp_file, 'r') as f: # noqa
|
2394
|
+
# curl_json = tmp_file.read()
|
2395
|
+
#
|
2396
|
+
# curl_res = json.loads(curl_json.decode().strip())
|
2397
|
+
#
|
2398
|
+
# status_code = check.isinstance(curl_res['response_code'], int)
|
2399
|
+
#
|
2400
|
+
# if not (200 <= status_code <= 300):
|
2401
|
+
# raise RuntimeError(f'Curl chunk download {chunk} failed: {curl_res}')
|
2402
|
+
|
2403
|
+
async def _download_file_chunk(self, chunk: _DownloadChunk) -> None:
|
2404
|
+
with log_timing_context(
|
2405
|
+
'Downloading github cache '
|
2406
|
+
f'key {chunk.key} '
|
2407
|
+
f'file {chunk.out_file} '
|
2408
|
+
f'chunk {chunk.offset} - {chunk.offset + chunk.size}',
|
2409
|
+
):
|
2410
|
+
await self._download_file_chunk_urllib(chunk)
|
2411
|
+
|
2412
|
+
async def _download_file(self, entry: GithubCacheServiceV1BaseClient.Entry, out_file: str) -> None:
|
2413
|
+
key = check.non_empty_str(entry.artifact.cache_key)
|
2414
|
+
url = check.non_empty_str(entry.artifact.archive_location)
|
2415
|
+
|
2416
|
+
head_resp, _ = await self.send_url_request(urllib.request.Request( # noqa
|
2417
|
+
url,
|
2418
|
+
method='HEAD',
|
2419
|
+
))
|
2420
|
+
file_size = int(head_resp.headers['Content-Length'])
|
2421
|
+
|
2422
|
+
#
|
2423
|
+
|
2424
|
+
with open(out_file, 'xb') as f: # noqa
|
2425
|
+
f.truncate(file_size)
|
2426
|
+
|
2427
|
+
#
|
2428
|
+
|
2429
|
+
download_tasks = []
|
2430
|
+
chunk_size = self._chunk_size
|
2431
|
+
for i in range((file_size // chunk_size) + (1 if file_size % chunk_size else 0)):
|
2432
|
+
offset = i * chunk_size
|
2433
|
+
size = min(chunk_size, file_size - offset)
|
2434
|
+
chunk = self._DownloadChunk(
|
2435
|
+
key,
|
2436
|
+
url,
|
2437
|
+
out_file,
|
2438
|
+
offset,
|
2439
|
+
size,
|
2440
|
+
)
|
2441
|
+
download_tasks.append(self._download_file_chunk(chunk))
|
2442
|
+
|
2443
|
+
await asyncio_wait_concurrent(download_tasks, self._concurrency)
|
2444
|
+
|
2445
|
+
async def download_file(self, entry: GithubCacheClient.Entry, out_file: str) -> None:
|
2446
|
+
entry1 = check.isinstance(entry, self.Entry)
|
2447
|
+
with log_timing_context(
|
2448
|
+
'Downloading github cache '
|
2449
|
+
f'key {entry1.artifact.cache_key} '
|
2450
|
+
f'version {entry1.artifact.cache_version} '
|
2451
|
+
f'to {out_file}',
|
2452
|
+
):
|
2453
|
+
await self._download_file(entry1, out_file)
|
2454
|
+
|
2455
|
+
#
|
2456
|
+
|
2457
|
+
async def _upload_file_chunk(
|
2458
|
+
self,
|
2459
|
+
key: str,
|
2460
|
+
cache_id: int,
|
2461
|
+
in_file: str,
|
2462
|
+
offset: int,
|
2463
|
+
size: int,
|
2464
|
+
) -> None:
|
2465
|
+
with log_timing_context(
|
2466
|
+
f'Uploading github cache {key} '
|
2467
|
+
f'file {in_file} '
|
2468
|
+
f'chunk {offset} - {offset + size}',
|
2469
|
+
):
|
2470
|
+
with open(in_file, 'rb') as f: # noqa
|
2471
|
+
f.seek(offset)
|
2472
|
+
buf = f.read(size)
|
2473
|
+
|
2474
|
+
check.equal(len(buf), size)
|
2475
|
+
|
2476
|
+
await self.send_service_request(
|
2477
|
+
f'caches/{cache_id}',
|
2478
|
+
method='PATCH',
|
2479
|
+
content_type='application/octet-stream',
|
2480
|
+
headers={
|
2481
|
+
'Content-Range': f'bytes {offset}-{offset + size - 1}/*',
|
2482
|
+
},
|
2483
|
+
content=buf,
|
2484
|
+
success_status_codes=[204],
|
2485
|
+
)
|
2486
|
+
|
2487
|
+
async def _upload_file(self, key: str, in_file: str) -> None:
|
2488
|
+
fixed_key = self.fix_key(key)
|
2489
|
+
|
2490
|
+
check.state(os.path.isfile(in_file))
|
2491
|
+
|
2492
|
+
file_size = os.stat(in_file).st_size
|
2493
|
+
|
2494
|
+
#
|
2495
|
+
|
2496
|
+
reserve_req = GithubCacheServiceV1.ReserveCacheRequest(
|
2497
|
+
key=fixed_key,
|
2498
|
+
cache_size=file_size,
|
2499
|
+
version=str(self._cache_version),
|
2500
|
+
)
|
2501
|
+
reserve_resp_obj = await self.send_service_request(
|
2502
|
+
'caches',
|
2503
|
+
json_content=GithubCacheServiceV1.dataclass_to_json(reserve_req),
|
2504
|
+
success_status_codes=[201],
|
2505
|
+
)
|
2506
|
+
reserve_resp = GithubCacheServiceV1.dataclass_from_json( # noqa
|
2507
|
+
GithubCacheServiceV1.ReserveCacheResponse,
|
2508
|
+
reserve_resp_obj,
|
2509
|
+
)
|
2510
|
+
cache_id = check.isinstance(reserve_resp.cache_id, int)
|
2511
|
+
|
2512
|
+
log.debug(f'Github cache file {os.path.basename(in_file)} got id {cache_id}') # noqa
|
2513
|
+
|
2514
|
+
#
|
2515
|
+
|
2516
|
+
upload_tasks = []
|
2517
|
+
chunk_size = self._chunk_size
|
2518
|
+
for i in range((file_size // chunk_size) + (1 if file_size % chunk_size else 0)):
|
2519
|
+
offset = i * chunk_size
|
2520
|
+
size = min(chunk_size, file_size - offset)
|
2521
|
+
upload_tasks.append(self._upload_file_chunk(
|
2522
|
+
fixed_key,
|
2523
|
+
cache_id,
|
2524
|
+
in_file,
|
2525
|
+
offset,
|
2526
|
+
size,
|
2527
|
+
))
|
2528
|
+
|
2529
|
+
await asyncio_wait_concurrent(upload_tasks, self._concurrency)
|
2530
|
+
|
2531
|
+
#
|
2532
|
+
|
2533
|
+
commit_req = GithubCacheServiceV1.CommitCacheRequest(
|
2534
|
+
size=file_size,
|
2535
|
+
)
|
2536
|
+
await self.send_service_request(
|
2537
|
+
f'caches/{cache_id}',
|
2538
|
+
json_content=GithubCacheServiceV1.dataclass_to_json(commit_req),
|
2539
|
+
success_status_codes=[204],
|
2540
|
+
)
|
2541
|
+
|
2542
|
+
async def upload_file(self, key: str, in_file: str) -> None:
|
2543
|
+
with log_timing_context(
|
2544
|
+
f'Uploading github cache file {os.path.basename(in_file)} '
|
2545
|
+
f'key {key}',
|
2546
|
+
):
|
2547
|
+
await self._upload_file(key, in_file)
|
2548
|
+
|
2549
|
+
|
2550
|
+
########################################
|
2551
|
+
# ../../../omlish/logs/standard.py
|
2552
|
+
"""
|
2553
|
+
TODO:
|
2554
|
+
- structured
|
2555
|
+
- prefixed
|
2556
|
+
- debug
|
2557
|
+
- optional noisy? noisy will never be lite - some kinda configure_standard callback mechanism?
|
2558
|
+
"""
|
2559
|
+
|
2560
|
+
|
2561
|
+
##
|
2562
|
+
|
2563
|
+
|
2564
|
+
STANDARD_LOG_FORMAT_PARTS = [
|
2565
|
+
('asctime', '%(asctime)-15s'),
|
2566
|
+
('process', 'pid=%(process)-6s'),
|
2567
|
+
('thread', 'tid=%(thread)x'),
|
2568
|
+
('levelname', '%(levelname)s'),
|
2569
|
+
('name', '%(name)s'),
|
2570
|
+
('separator', '::'),
|
2571
|
+
('message', '%(message)s'),
|
2572
|
+
]
|
2573
|
+
|
2574
|
+
|
2575
|
+
class StandardLogFormatter(logging.Formatter):
|
2576
|
+
@staticmethod
|
2577
|
+
def build_log_format(parts: ta.Iterable[ta.Tuple[str, str]]) -> str:
|
2578
|
+
return ' '.join(v for k, v in parts)
|
2579
|
+
|
2580
|
+
converter = datetime.datetime.fromtimestamp # type: ignore
|
2581
|
+
|
2582
|
+
def formatTime(self, record, datefmt=None):
|
2583
|
+
ct = self.converter(record.created) # type: ignore
|
2584
|
+
if datefmt:
|
2585
|
+
return ct.strftime(datefmt) # noqa
|
2586
|
+
else:
|
2587
|
+
t = ct.strftime('%Y-%m-%d %H:%M:%S')
|
2588
|
+
return '%s.%03d' % (t, record.msecs) # noqa
|
2589
|
+
|
2590
|
+
|
2591
|
+
##
|
2592
|
+
|
2593
|
+
|
2594
|
+
class StandardConfiguredLogHandler(ProxyLogHandler):
|
2595
|
+
def __init_subclass__(cls, **kwargs):
|
2596
|
+
raise TypeError('This class serves only as a marker and should not be subclassed.')
|
2597
|
+
|
2598
|
+
|
2599
|
+
##
|
1879
2600
|
|
1880
2601
|
|
1881
2602
|
@contextlib.contextmanager
|
@@ -2276,6 +2997,395 @@ class AbstractAsyncSubprocesses(BaseSubprocesses):
|
|
2276
2997
|
return ret.decode().strip()
|
2277
2998
|
|
2278
2999
|
|
3000
|
+
########################################
|
3001
|
+
# ../github/cache.py
|
3002
|
+
|
3003
|
+
|
3004
|
+
##
|
3005
|
+
|
3006
|
+
|
3007
|
+
class GithubFileCache(FileCache):
|
3008
|
+
def __init__(
|
3009
|
+
self,
|
3010
|
+
dir: str, # noqa
|
3011
|
+
*,
|
3012
|
+
client: ta.Optional[GithubCacheClient] = None,
|
3013
|
+
**kwargs: ta.Any,
|
3014
|
+
) -> None:
|
3015
|
+
super().__init__(**kwargs)
|
3016
|
+
|
3017
|
+
self._dir = check.not_none(dir)
|
3018
|
+
|
3019
|
+
if client is None:
|
3020
|
+
client = GithubCacheServiceV1Client(
|
3021
|
+
cache_version=self._version,
|
3022
|
+
)
|
3023
|
+
self._client: GithubCacheClient = client
|
3024
|
+
|
3025
|
+
self._local = DirectoryFileCache(
|
3026
|
+
self._dir,
|
3027
|
+
version=self._version,
|
3028
|
+
)
|
3029
|
+
|
3030
|
+
async def get_file(self, key: str) -> ta.Optional[str]:
|
3031
|
+
local_file = self._local.get_cache_file_path(key)
|
3032
|
+
if os.path.exists(local_file):
|
3033
|
+
return local_file
|
3034
|
+
|
3035
|
+
if (entry := await self._client.get_entry(key)) is None:
|
3036
|
+
return None
|
3037
|
+
|
3038
|
+
tmp_file = self._local.format_incomplete_file(local_file)
|
3039
|
+
with unlinking_if_exists(tmp_file):
|
3040
|
+
await self._client.download_file(entry, tmp_file)
|
3041
|
+
|
3042
|
+
os.replace(tmp_file, local_file)
|
3043
|
+
|
3044
|
+
return local_file
|
3045
|
+
|
3046
|
+
async def put_file(
|
3047
|
+
self,
|
3048
|
+
key: str,
|
3049
|
+
file_path: str,
|
3050
|
+
*,
|
3051
|
+
steal: bool = False,
|
3052
|
+
) -> str:
|
3053
|
+
cache_file_path = await self._local.put_file(
|
3054
|
+
key,
|
3055
|
+
file_path,
|
3056
|
+
steal=steal,
|
3057
|
+
)
|
3058
|
+
|
3059
|
+
await self._client.upload_file(key, cache_file_path)
|
3060
|
+
|
3061
|
+
return cache_file_path
|
3062
|
+
|
3063
|
+
|
3064
|
+
########################################
|
3065
|
+
# ../github/cli.py
|
3066
|
+
"""
|
3067
|
+
See:
|
3068
|
+
- https://docs.github.com/en/rest/actions/cache?apiVersion=2022-11-28
|
3069
|
+
"""
|
3070
|
+
|
3071
|
+
|
3072
|
+
class GithubCli(ArgparseCli):
|
3073
|
+
@argparse_cmd()
|
3074
|
+
def list_referenced_env_vars(self) -> None:
|
3075
|
+
print('\n'.join(sorted(ev.k for ev in GITHUB_ENV_VARS)))
|
3076
|
+
|
3077
|
+
@argparse_cmd(
|
3078
|
+
argparse_arg('key'),
|
3079
|
+
)
|
3080
|
+
async def get_cache_entry(self) -> None:
|
3081
|
+
client = GithubCacheServiceV1Client()
|
3082
|
+
entry = await client.get_entry(self.args.key)
|
3083
|
+
if entry is None:
|
3084
|
+
return
|
3085
|
+
print(json_dumps_pretty(dc.asdict(entry))) # noqa
|
3086
|
+
|
3087
|
+
@argparse_cmd(
|
3088
|
+
argparse_arg('repository-id'),
|
3089
|
+
)
|
3090
|
+
def list_cache_entries(self) -> None:
|
3091
|
+
raise NotImplementedError
|
3092
|
+
|
3093
|
+
|
3094
|
+
########################################
|
3095
|
+
# ../requirements.py
|
3096
|
+
"""
|
3097
|
+
TODO:
|
3098
|
+
- pip compile lol
|
3099
|
+
- but still support git+ stuff
|
3100
|
+
- req.txt format aware hash
|
3101
|
+
- more than just whitespace
|
3102
|
+
- pyproject req rewriting
|
3103
|
+
- download_requirements bootstrap off prev? not worth the dl?
|
3104
|
+
- big deps (torch) change less, probably worth it
|
3105
|
+
- follow embedded -r automatically like pyp
|
3106
|
+
"""
|
3107
|
+
|
3108
|
+
|
3109
|
+
##
|
3110
|
+
|
3111
|
+
|
3112
|
+
def build_requirements_hash(
|
3113
|
+
requirements_txts: ta.Sequence[str],
|
3114
|
+
) -> str:
|
3115
|
+
txt_file_contents: dict = {}
|
3116
|
+
|
3117
|
+
for txt_file in requirements_txts:
|
3118
|
+
txt_file_name = os.path.basename(txt_file)
|
3119
|
+
check.not_in(txt_file_name, txt_file_contents)
|
3120
|
+
with open(txt_file) as f:
|
3121
|
+
txt_contents = f.read()
|
3122
|
+
txt_file_contents[txt_file_name] = txt_contents
|
3123
|
+
|
3124
|
+
#
|
3125
|
+
|
3126
|
+
lines = []
|
3127
|
+
for txt_file, txt_contents in sorted(txt_file_contents.items()):
|
3128
|
+
txt_hash = sha256_str(txt_contents)
|
3129
|
+
lines.append(f'{txt_file}={txt_hash}')
|
3130
|
+
|
3131
|
+
return sha256_str('\n'.join(lines))
|
3132
|
+
|
3133
|
+
|
3134
|
+
##
|
3135
|
+
|
3136
|
+
|
3137
|
+
def download_requirements(
|
3138
|
+
image: str,
|
3139
|
+
requirements_dir: str,
|
3140
|
+
requirements_txts: ta.Sequence[str],
|
3141
|
+
) -> None:
|
3142
|
+
requirements_txt_dir = tempfile.mkdtemp()
|
3143
|
+
with defer(lambda: shutil.rmtree(requirements_txt_dir)):
|
3144
|
+
for rt in requirements_txts:
|
3145
|
+
shutil.copyfile(rt, os.path.join(requirements_txt_dir, os.path.basename(rt)))
|
3146
|
+
|
3147
|
+
subprocesses.check_call(
|
3148
|
+
'docker',
|
3149
|
+
'run',
|
3150
|
+
'--rm',
|
3151
|
+
'-i',
|
3152
|
+
'-v', f'{os.path.abspath(requirements_dir)}:/requirements',
|
3153
|
+
'-v', f'{requirements_txt_dir}:/requirements_txt',
|
3154
|
+
image,
|
3155
|
+
'pip',
|
3156
|
+
'download',
|
3157
|
+
'-d', '/requirements',
|
3158
|
+
*itertools.chain.from_iterable(
|
3159
|
+
['-r', f'/requirements_txt/{os.path.basename(rt)}']
|
3160
|
+
for rt in requirements_txts
|
3161
|
+
),
|
3162
|
+
)
|
3163
|
+
|
3164
|
+
|
3165
|
+
########################################
|
3166
|
+
# ../../../omlish/asyncs/asyncio/subprocesses.py
|
3167
|
+
|
3168
|
+
|
3169
|
+
##
|
3170
|
+
|
3171
|
+
|
3172
|
+
class AsyncioProcessCommunicator:
|
3173
|
+
def __init__(
|
3174
|
+
self,
|
3175
|
+
proc: asyncio.subprocess.Process,
|
3176
|
+
loop: ta.Optional[ta.Any] = None,
|
3177
|
+
*,
|
3178
|
+
log: ta.Optional[logging.Logger] = None,
|
3179
|
+
) -> None:
|
3180
|
+
super().__init__()
|
3181
|
+
|
3182
|
+
if loop is None:
|
3183
|
+
loop = asyncio.get_running_loop()
|
3184
|
+
|
3185
|
+
self._proc = proc
|
3186
|
+
self._loop = loop
|
3187
|
+
self._log = log
|
3188
|
+
|
3189
|
+
self._transport: asyncio.base_subprocess.BaseSubprocessTransport = check.isinstance(
|
3190
|
+
proc._transport, # type: ignore # noqa
|
3191
|
+
asyncio.base_subprocess.BaseSubprocessTransport,
|
3192
|
+
)
|
3193
|
+
|
3194
|
+
@property
|
3195
|
+
def _debug(self) -> bool:
|
3196
|
+
return self._loop.get_debug()
|
3197
|
+
|
3198
|
+
async def _feed_stdin(self, input: bytes) -> None: # noqa
|
3199
|
+
stdin = check.not_none(self._proc.stdin)
|
3200
|
+
try:
|
3201
|
+
if input is not None:
|
3202
|
+
stdin.write(input)
|
3203
|
+
if self._debug and self._log is not None:
|
3204
|
+
self._log.debug('%r communicate: feed stdin (%s bytes)', self, len(input))
|
3205
|
+
|
3206
|
+
await stdin.drain()
|
3207
|
+
|
3208
|
+
except (BrokenPipeError, ConnectionResetError) as exc:
|
3209
|
+
# communicate() ignores BrokenPipeError and ConnectionResetError. write() and drain() can raise these
|
3210
|
+
# exceptions.
|
3211
|
+
if self._debug and self._log is not None:
|
3212
|
+
self._log.debug('%r communicate: stdin got %r', self, exc)
|
3213
|
+
|
3214
|
+
if self._debug and self._log is not None:
|
3215
|
+
self._log.debug('%r communicate: close stdin', self)
|
3216
|
+
|
3217
|
+
stdin.close()
|
3218
|
+
|
3219
|
+
async def _noop(self) -> None:
|
3220
|
+
return None
|
3221
|
+
|
3222
|
+
async def _read_stream(self, fd: int) -> bytes:
|
3223
|
+
transport: ta.Any = check.not_none(self._transport.get_pipe_transport(fd))
|
3224
|
+
|
3225
|
+
if fd == 2:
|
3226
|
+
stream = check.not_none(self._proc.stderr)
|
3227
|
+
else:
|
3228
|
+
check.equal(fd, 1)
|
3229
|
+
stream = check.not_none(self._proc.stdout)
|
3230
|
+
|
3231
|
+
if self._debug and self._log is not None:
|
3232
|
+
name = 'stdout' if fd == 1 else 'stderr'
|
3233
|
+
self._log.debug('%r communicate: read %s', self, name)
|
3234
|
+
|
3235
|
+
output = await stream.read()
|
3236
|
+
|
3237
|
+
if self._debug and self._log is not None:
|
3238
|
+
name = 'stdout' if fd == 1 else 'stderr'
|
3239
|
+
self._log.debug('%r communicate: close %s', self, name)
|
3240
|
+
|
3241
|
+
transport.close()
|
3242
|
+
|
3243
|
+
return output
|
3244
|
+
|
3245
|
+
class Communication(ta.NamedTuple):
|
3246
|
+
stdout: ta.Optional[bytes]
|
3247
|
+
stderr: ta.Optional[bytes]
|
3248
|
+
|
3249
|
+
async def _communicate(
|
3250
|
+
self,
|
3251
|
+
input: ta.Any = None, # noqa
|
3252
|
+
) -> Communication:
|
3253
|
+
stdin_fut: ta.Any
|
3254
|
+
if self._proc.stdin is not None:
|
3255
|
+
stdin_fut = self._feed_stdin(input)
|
3256
|
+
else:
|
3257
|
+
stdin_fut = self._noop()
|
3258
|
+
|
3259
|
+
stdout_fut: ta.Any
|
3260
|
+
if self._proc.stdout is not None:
|
3261
|
+
stdout_fut = self._read_stream(1)
|
3262
|
+
else:
|
3263
|
+
stdout_fut = self._noop()
|
3264
|
+
|
3265
|
+
stderr_fut: ta.Any
|
3266
|
+
if self._proc.stderr is not None:
|
3267
|
+
stderr_fut = self._read_stream(2)
|
3268
|
+
else:
|
3269
|
+
stderr_fut = self._noop()
|
3270
|
+
|
3271
|
+
stdin_res, stdout_res, stderr_res = await asyncio.gather(stdin_fut, stdout_fut, stderr_fut)
|
3272
|
+
|
3273
|
+
await self._proc.wait()
|
3274
|
+
|
3275
|
+
return AsyncioProcessCommunicator.Communication(stdout_res, stderr_res)
|
3276
|
+
|
3277
|
+
async def communicate(
|
3278
|
+
self,
|
3279
|
+
input: ta.Any = None, # noqa
|
3280
|
+
timeout: ta.Optional[float] = None,
|
3281
|
+
) -> Communication:
|
3282
|
+
return await asyncio_maybe_timeout(self._communicate(input), timeout)
|
3283
|
+
|
3284
|
+
|
3285
|
+
##
|
3286
|
+
|
3287
|
+
|
3288
|
+
class AsyncioSubprocesses(AbstractAsyncSubprocesses):
|
3289
|
+
async def communicate(
|
3290
|
+
self,
|
3291
|
+
proc: asyncio.subprocess.Process,
|
3292
|
+
input: ta.Any = None, # noqa
|
3293
|
+
timeout: ta.Optional[float] = None,
|
3294
|
+
) -> ta.Tuple[ta.Optional[bytes], ta.Optional[bytes]]:
|
3295
|
+
return await AsyncioProcessCommunicator(proc).communicate(input, timeout) # noqa
|
3296
|
+
|
3297
|
+
#
|
3298
|
+
|
3299
|
+
@contextlib.asynccontextmanager
|
3300
|
+
async def popen(
|
3301
|
+
self,
|
3302
|
+
*cmd: str,
|
3303
|
+
shell: bool = False,
|
3304
|
+
timeout: ta.Optional[float] = None,
|
3305
|
+
**kwargs: ta.Any,
|
3306
|
+
) -> ta.AsyncGenerator[asyncio.subprocess.Process, None]:
|
3307
|
+
fac: ta.Any
|
3308
|
+
if shell:
|
3309
|
+
fac = functools.partial(
|
3310
|
+
asyncio.create_subprocess_shell,
|
3311
|
+
check.single(cmd),
|
3312
|
+
)
|
3313
|
+
else:
|
3314
|
+
fac = functools.partial(
|
3315
|
+
asyncio.create_subprocess_exec,
|
3316
|
+
*cmd,
|
3317
|
+
)
|
3318
|
+
|
3319
|
+
with self.prepare_and_wrap( *cmd, shell=shell, **kwargs) as (cmd, kwargs): # noqa
|
3320
|
+
proc: asyncio.subprocess.Process = await fac(**kwargs)
|
3321
|
+
try:
|
3322
|
+
yield proc
|
3323
|
+
|
3324
|
+
finally:
|
3325
|
+
await asyncio_maybe_timeout(proc.wait(), timeout)
|
3326
|
+
|
3327
|
+
#
|
3328
|
+
|
3329
|
+
@dc.dataclass(frozen=True)
|
3330
|
+
class RunOutput:
|
3331
|
+
proc: asyncio.subprocess.Process
|
3332
|
+
stdout: ta.Optional[bytes]
|
3333
|
+
stderr: ta.Optional[bytes]
|
3334
|
+
|
3335
|
+
async def run(
|
3336
|
+
self,
|
3337
|
+
*cmd: str,
|
3338
|
+
input: ta.Any = None, # noqa
|
3339
|
+
timeout: ta.Optional[float] = None,
|
3340
|
+
check: bool = False, # noqa
|
3341
|
+
capture_output: ta.Optional[bool] = None,
|
3342
|
+
**kwargs: ta.Any,
|
3343
|
+
) -> RunOutput:
|
3344
|
+
if capture_output:
|
3345
|
+
kwargs.setdefault('stdout', subprocess.PIPE)
|
3346
|
+
kwargs.setdefault('stderr', subprocess.PIPE)
|
3347
|
+
|
3348
|
+
proc: asyncio.subprocess.Process
|
3349
|
+
async with self.popen(*cmd, **kwargs) as proc:
|
3350
|
+
stdout, stderr = await self.communicate(proc, input, timeout)
|
3351
|
+
|
3352
|
+
if check and proc.returncode:
|
3353
|
+
raise subprocess.CalledProcessError(
|
3354
|
+
proc.returncode,
|
3355
|
+
cmd,
|
3356
|
+
output=stdout,
|
3357
|
+
stderr=stderr,
|
3358
|
+
)
|
3359
|
+
|
3360
|
+
return self.RunOutput(
|
3361
|
+
proc,
|
3362
|
+
stdout,
|
3363
|
+
stderr,
|
3364
|
+
)
|
3365
|
+
|
3366
|
+
#
|
3367
|
+
|
3368
|
+
async def check_call(
|
3369
|
+
self,
|
3370
|
+
*cmd: str,
|
3371
|
+
stdout: ta.Any = sys.stderr,
|
3372
|
+
**kwargs: ta.Any,
|
3373
|
+
) -> None:
|
3374
|
+
with self.prepare_and_wrap(*cmd, stdout=stdout, check=True, **kwargs) as (cmd, kwargs): # noqa
|
3375
|
+
await self.run(*cmd, **kwargs)
|
3376
|
+
|
3377
|
+
async def check_output(
|
3378
|
+
self,
|
3379
|
+
*cmd: str,
|
3380
|
+
**kwargs: ta.Any,
|
3381
|
+
) -> bytes:
|
3382
|
+
with self.prepare_and_wrap(*cmd, stdout=subprocess.PIPE, check=True, **kwargs) as (cmd, kwargs): # noqa
|
3383
|
+
return check.not_none((await self.run(*cmd, **kwargs)).stdout)
|
3384
|
+
|
3385
|
+
|
3386
|
+
asyncio_subprocesses = AsyncioSubprocesses()
|
3387
|
+
|
3388
|
+
|
2279
3389
|
########################################
|
2280
3390
|
# ../compose.py
|
2281
3391
|
"""
|
@@ -2307,7 +3417,7 @@ def get_compose_service_dependencies(
|
|
2307
3417
|
##
|
2308
3418
|
|
2309
3419
|
|
2310
|
-
class DockerComposeRun(
|
3420
|
+
class DockerComposeRun(AsyncExitStacked):
|
2311
3421
|
@dc.dataclass(frozen=True)
|
2312
3422
|
class Config:
|
2313
3423
|
compose_file: str
|
@@ -2325,6 +3435,7 @@ class DockerComposeRun(ExitStacked):
|
|
2325
3435
|
|
2326
3436
|
#
|
2327
3437
|
|
3438
|
+
no_dependencies: bool = False
|
2328
3439
|
no_dependency_cleanup: bool = False
|
2329
3440
|
|
2330
3441
|
#
|
@@ -2343,40 +3454,6 @@ class DockerComposeRun(ExitStacked):
|
|
2343
3454
|
|
2344
3455
|
#
|
2345
3456
|
|
2346
|
-
@property
|
2347
|
-
def image_tag(self) -> str:
|
2348
|
-
pfx = 'sha256:'
|
2349
|
-
if (image := self._cfg.image).startswith(pfx):
|
2350
|
-
image = image[len(pfx):]
|
2351
|
-
|
2352
|
-
return f'{self._cfg.service}:{image}'
|
2353
|
-
|
2354
|
-
@cached_nullary
|
2355
|
-
def tag_image(self) -> str:
|
2356
|
-
image_tag = self.image_tag
|
2357
|
-
|
2358
|
-
subprocesses.check_call(
|
2359
|
-
'docker',
|
2360
|
-
'tag',
|
2361
|
-
self._cfg.image,
|
2362
|
-
image_tag,
|
2363
|
-
**self._subprocess_kwargs,
|
2364
|
-
)
|
2365
|
-
|
2366
|
-
def delete_tag() -> None:
|
2367
|
-
subprocesses.check_call(
|
2368
|
-
'docker',
|
2369
|
-
'rmi',
|
2370
|
-
image_tag,
|
2371
|
-
**self._subprocess_kwargs,
|
2372
|
-
)
|
2373
|
-
|
2374
|
-
self._enter_context(defer(delete_tag)) # noqa
|
2375
|
-
|
2376
|
-
return image_tag
|
2377
|
-
|
2378
|
-
#
|
2379
|
-
|
2380
3457
|
def _rewrite_compose_dct(self, in_dct: ta.Dict[str, ta.Any]) -> ta.Dict[str, ta.Any]:
|
2381
3458
|
out = dict(in_dct)
|
2382
3459
|
|
@@ -2390,29 +3467,28 @@ class DockerComposeRun(ExitStacked):
|
|
2390
3467
|
in_service: dict = in_services[self._cfg.service]
|
2391
3468
|
out_services[self._cfg.service] = out_service = dict(in_service)
|
2392
3469
|
|
2393
|
-
out_service['image'] = self.
|
3470
|
+
out_service['image'] = self._cfg.image
|
2394
3471
|
|
2395
3472
|
for k in ['build', 'platform']:
|
2396
3473
|
if k in out_service:
|
2397
|
-
del out_service[k]
|
2398
|
-
|
2399
|
-
out_service['links'] = [
|
2400
|
-
f'{l}:{l}' if ':' not in l else l
|
2401
|
-
for l in out_service.get('links', [])
|
2402
|
-
]
|
3474
|
+
del out_service[k]
|
2403
3475
|
|
2404
3476
|
#
|
2405
3477
|
|
2406
|
-
|
3478
|
+
if not self._cfg.no_dependencies:
|
3479
|
+
depends_on = in_service.get('depends_on', [])
|
2407
3480
|
|
2408
|
-
|
2409
|
-
|
2410
|
-
|
3481
|
+
for dep_service, in_dep_service_dct in list(in_services.items()):
|
3482
|
+
if dep_service not in depends_on:
|
3483
|
+
continue
|
2411
3484
|
|
2412
|
-
|
2413
|
-
|
3485
|
+
out_dep_service: dict = dict(in_dep_service_dct)
|
3486
|
+
out_services[dep_service] = out_dep_service
|
2414
3487
|
|
2415
|
-
|
3488
|
+
out_dep_service['ports'] = []
|
3489
|
+
|
3490
|
+
else:
|
3491
|
+
out_service['depends_on'] = []
|
2416
3492
|
|
2417
3493
|
#
|
2418
3494
|
|
@@ -2438,22 +3514,20 @@ class DockerComposeRun(ExitStacked):
|
|
2438
3514
|
|
2439
3515
|
#
|
2440
3516
|
|
2441
|
-
def _cleanup_dependencies(self) -> None:
|
2442
|
-
|
3517
|
+
async def _cleanup_dependencies(self) -> None:
|
3518
|
+
await asyncio_subprocesses.check_call(
|
2443
3519
|
'docker',
|
2444
3520
|
'compose',
|
2445
3521
|
'-f', self.rewrite_compose_file(),
|
2446
3522
|
'down',
|
2447
3523
|
)
|
2448
3524
|
|
2449
|
-
def run(self) -> None:
|
2450
|
-
self.tag_image()
|
2451
|
-
|
3525
|
+
async def run(self) -> None:
|
2452
3526
|
compose_file = self.rewrite_compose_file()
|
2453
3527
|
|
2454
|
-
with contextlib.
|
2455
|
-
if not self._cfg.no_dependency_cleanup:
|
2456
|
-
es.
|
3528
|
+
async with contextlib.AsyncExitStack() as es:
|
3529
|
+
if not (self._cfg.no_dependencies or self._cfg.no_dependency_cleanup):
|
3530
|
+
await es.enter_async_context(adefer(self._cleanup_dependencies)) # noqa
|
2457
3531
|
|
2458
3532
|
sh_cmd = ' '.join([
|
2459
3533
|
'docker',
|
@@ -2472,8 +3546,8 @@ class DockerComposeRun(ExitStacked):
|
|
2472
3546
|
|
2473
3547
|
run_cmd = dc.replace(self._cfg.cmd, s=sh_cmd)
|
2474
3548
|
|
2475
|
-
run_cmd.run(
|
2476
|
-
|
3549
|
+
await run_cmd.run(
|
3550
|
+
asyncio_subprocesses.check_call,
|
2477
3551
|
**self._subprocess_kwargs,
|
2478
3552
|
)
|
2479
3553
|
|
@@ -2505,535 +3579,142 @@ def read_docker_tar_image_tag(tar_file: str) -> str:
|
|
2505
3579
|
with contextlib.closing(check.not_none(tf.extractfile('manifest.json'))) as mf:
|
2506
3580
|
m = mf.read()
|
2507
3581
|
|
2508
|
-
manifests = json.loads(m.decode('utf-8'))
|
2509
|
-
manifest = check.single(manifests)
|
2510
|
-
tag = check.non_empty_str(check.single(manifest['RepoTags']))
|
2511
|
-
return tag
|
2512
|
-
|
2513
|
-
|
2514
|
-
def read_docker_tar_image_id(tar_file: str) -> str:
|
2515
|
-
with tarfile.open(tar_file) as tf:
|
2516
|
-
with contextlib.closing(check.not_none(tf.extractfile('index.json'))) as mf:
|
2517
|
-
i = mf.read()
|
2518
|
-
|
2519
|
-
index = json.loads(i.decode('utf-8'))
|
2520
|
-
manifest = check.single(index['manifests'])
|
2521
|
-
image_id = check.non_empty_str(manifest['digest'])
|
2522
|
-
return image_id
|
2523
|
-
|
2524
|
-
|
2525
|
-
##
|
2526
|
-
|
2527
|
-
|
2528
|
-
def is_docker_image_present(image: str) -> bool:
|
2529
|
-
out = subprocesses.check_output(
|
2530
|
-
'docker',
|
2531
|
-
'images',
|
2532
|
-
'--format', 'json',
|
2533
|
-
image,
|
2534
|
-
)
|
2535
|
-
|
2536
|
-
out_s = out.decode('utf-8').strip()
|
2537
|
-
if not out_s:
|
2538
|
-
return False
|
2539
|
-
|
2540
|
-
json.loads(out_s) # noqa
|
2541
|
-
return True
|
2542
|
-
|
2543
|
-
|
2544
|
-
def pull_docker_image(
|
2545
|
-
image: str,
|
2546
|
-
) -> None:
|
2547
|
-
subprocesses.check_call(
|
2548
|
-
'docker',
|
2549
|
-
'pull',
|
2550
|
-
image,
|
2551
|
-
)
|
2552
|
-
|
2553
|
-
|
2554
|
-
def build_docker_image(
|
2555
|
-
docker_file: str,
|
2556
|
-
*,
|
2557
|
-
cwd: ta.Optional[str] = None,
|
2558
|
-
) -> str:
|
2559
|
-
id_file = make_temp_file()
|
2560
|
-
with defer(lambda: os.unlink(id_file)):
|
2561
|
-
subprocesses.check_call(
|
2562
|
-
'docker',
|
2563
|
-
'build',
|
2564
|
-
'-f', os.path.abspath(docker_file),
|
2565
|
-
'--iidfile', id_file,
|
2566
|
-
'--squash',
|
2567
|
-
'.',
|
2568
|
-
**(dict(cwd=cwd) if cwd is not None else {}),
|
2569
|
-
)
|
2570
|
-
|
2571
|
-
with open(id_file) as f:
|
2572
|
-
image_id = check.single(f.read().strip().splitlines()).strip()
|
2573
|
-
|
2574
|
-
return image_id
|
2575
|
-
|
2576
|
-
|
2577
|
-
##
|
2578
|
-
|
2579
|
-
|
2580
|
-
def save_docker_tar_cmd(
|
2581
|
-
image: str,
|
2582
|
-
output_cmd: ShellCmd,
|
2583
|
-
) -> None:
|
2584
|
-
cmd = dc.replace(output_cmd, s=f'docker save {image} | {output_cmd.s}')
|
2585
|
-
cmd.run(subprocesses.check_call)
|
2586
|
-
|
2587
|
-
|
2588
|
-
def save_docker_tar(
|
2589
|
-
image: str,
|
2590
|
-
tar_file: str,
|
2591
|
-
) -> None:
|
2592
|
-
return save_docker_tar_cmd(
|
2593
|
-
image,
|
2594
|
-
ShellCmd(f'cat > {shlex.quote(tar_file)}'),
|
2595
|
-
)
|
2596
|
-
|
2597
|
-
|
2598
|
-
#
|
2599
|
-
|
2600
|
-
|
2601
|
-
def load_docker_tar_cmd(
|
2602
|
-
input_cmd: ShellCmd,
|
2603
|
-
) -> str:
|
2604
|
-
cmd = dc.replace(input_cmd, s=f'{input_cmd.s} | docker load')
|
2605
|
-
|
2606
|
-
out = cmd.run(subprocesses.check_output).decode()
|
2607
|
-
|
2608
|
-
line = check.single(out.strip().splitlines())
|
2609
|
-
loaded = line.partition(':')[2].strip()
|
2610
|
-
return loaded
|
2611
|
-
|
2612
|
-
|
2613
|
-
def load_docker_tar(
|
2614
|
-
tar_file: str,
|
2615
|
-
) -> str:
|
2616
|
-
return load_docker_tar_cmd(ShellCmd(f'cat {shlex.quote(tar_file)}'))
|
2617
|
-
|
2618
|
-
|
2619
|
-
########################################
|
2620
|
-
# ../github/cache.py
|
2621
|
-
|
2622
|
-
|
2623
|
-
##
|
2624
|
-
|
2625
|
-
|
2626
|
-
class GithubV1CacheShellClient:
|
2627
|
-
BASE_URL_ENV_KEY = 'ACTIONS_CACHE_URL'
|
2628
|
-
AUTH_TOKEN_ENV_KEY = 'ACTIONS_RUNTIME_TOKEN' # noqa
|
2629
|
-
|
2630
|
-
def __init__(
|
2631
|
-
self,
|
2632
|
-
*,
|
2633
|
-
base_url: ta.Optional[str] = None,
|
2634
|
-
auth_token: ta.Optional[str] = None,
|
2635
|
-
) -> None:
|
2636
|
-
super().__init__()
|
2637
|
-
|
2638
|
-
if base_url is None:
|
2639
|
-
base_url = os.environ[self.BASE_URL_ENV_KEY]
|
2640
|
-
self._base_url = check.non_empty_str(base_url)
|
2641
|
-
|
2642
|
-
if auth_token is None:
|
2643
|
-
auth_token = os.environ.get(self.AUTH_TOKEN_ENV_KEY)
|
2644
|
-
self._auth_token = auth_token
|
2645
|
-
|
2646
|
-
self._service_url = GithubCacheServiceV1.get_service_url(self._base_url)
|
2647
|
-
|
2648
|
-
#
|
2649
|
-
|
2650
|
-
_MISSING = object()
|
2651
|
-
|
2652
|
-
def build_headers(
|
2653
|
-
self,
|
2654
|
-
*,
|
2655
|
-
auth_token: ta.Any = _MISSING,
|
2656
|
-
content_type: ta.Optional[str] = None,
|
2657
|
-
) -> ta.Dict[str, str]:
|
2658
|
-
dct = {
|
2659
|
-
'Accept': f'application/json;api-version={GithubCacheServiceV1.API_VERSION}',
|
2660
|
-
}
|
2661
|
-
|
2662
|
-
if auth_token is self._MISSING:
|
2663
|
-
auth_token = self._auth_token
|
2664
|
-
if auth_token:
|
2665
|
-
dct['Authorization'] = f'Bearer {auth_token}'
|
2666
|
-
|
2667
|
-
if content_type is not None:
|
2668
|
-
dct['Content-Type'] = content_type
|
2669
|
-
|
2670
|
-
return dct
|
2671
|
-
|
2672
|
-
#
|
2673
|
-
|
2674
|
-
HEADER_AUTH_TOKEN_ENV_KEY = '_GITHUB_CACHE_AUTH_TOKEN' # noqa
|
2675
|
-
|
2676
|
-
def build_curl_cmd(
|
2677
|
-
self,
|
2678
|
-
method: str,
|
2679
|
-
url: str,
|
2680
|
-
*,
|
2681
|
-
json_content: bool = False,
|
2682
|
-
content_type: ta.Optional[str] = None,
|
2683
|
-
) -> ShellCmd:
|
2684
|
-
if content_type is None and json_content:
|
2685
|
-
content_type = 'application/json'
|
2686
|
-
|
2687
|
-
env = {}
|
2688
|
-
|
2689
|
-
header_auth_token: ta.Optional[str]
|
2690
|
-
if self._auth_token:
|
2691
|
-
env[self.HEADER_AUTH_TOKEN_ENV_KEY] = self._auth_token
|
2692
|
-
header_auth_token = f'${self.HEADER_AUTH_TOKEN_ENV_KEY}'
|
2693
|
-
else:
|
2694
|
-
header_auth_token = None
|
2695
|
-
|
2696
|
-
hdrs = self.build_headers(
|
2697
|
-
auth_token=header_auth_token,
|
2698
|
-
content_type=content_type,
|
2699
|
-
)
|
2700
|
-
|
2701
|
-
url = f'{self._service_url}/{url}'
|
2702
|
-
|
2703
|
-
cmd = ' '.join([
|
2704
|
-
'curl',
|
2705
|
-
'-s',
|
2706
|
-
'-X', method,
|
2707
|
-
url,
|
2708
|
-
*[f'-H "{k}: {v}"' for k, v in hdrs.items()],
|
2709
|
-
])
|
2710
|
-
|
2711
|
-
return ShellCmd(
|
2712
|
-
cmd,
|
2713
|
-
env=env,
|
2714
|
-
)
|
2715
|
-
|
2716
|
-
def build_post_json_curl_cmd(
|
2717
|
-
self,
|
2718
|
-
url: str,
|
2719
|
-
obj: ta.Any,
|
2720
|
-
**kwargs: ta.Any,
|
2721
|
-
) -> ShellCmd:
|
2722
|
-
curl_cmd = self.build_curl_cmd(
|
2723
|
-
'POST',
|
2724
|
-
url,
|
2725
|
-
json_content=True,
|
2726
|
-
**kwargs,
|
2727
|
-
)
|
2728
|
-
|
2729
|
-
obj_json = json_dumps_compact(obj)
|
2730
|
-
|
2731
|
-
return dc.replace(curl_cmd, s=f'{curl_cmd.s} -d {shlex.quote(obj_json)}')
|
2732
|
-
|
2733
|
-
#
|
2734
|
-
|
2735
|
-
@dc.dataclass()
|
2736
|
-
class CurlError(RuntimeError):
|
2737
|
-
status_code: int
|
2738
|
-
body: ta.Optional[bytes]
|
2739
|
-
|
2740
|
-
def __str__(self) -> str:
|
2741
|
-
return repr(self)
|
2742
|
-
|
2743
|
-
@dc.dataclass(frozen=True)
|
2744
|
-
class CurlResult:
|
2745
|
-
status_code: int
|
2746
|
-
body: ta.Optional[bytes]
|
2747
|
-
|
2748
|
-
def as_error(self) -> 'GithubV1CacheShellClient.CurlError':
|
2749
|
-
return GithubV1CacheShellClient.CurlError(
|
2750
|
-
status_code=self.status_code,
|
2751
|
-
body=self.body,
|
2752
|
-
)
|
2753
|
-
|
2754
|
-
def run_curl_cmd(
|
2755
|
-
self,
|
2756
|
-
cmd: ShellCmd,
|
2757
|
-
*,
|
2758
|
-
raise_: bool = False,
|
2759
|
-
) -> CurlResult:
|
2760
|
-
out_file = make_temp_file()
|
2761
|
-
with defer(lambda: os.unlink(out_file)):
|
2762
|
-
run_cmd = dc.replace(cmd, s=f"{cmd.s} -o {out_file} -w '%{{json}}'")
|
2763
|
-
|
2764
|
-
out_json_bytes = run_cmd.run(subprocesses.check_output)
|
2765
|
-
|
2766
|
-
out_json = json.loads(out_json_bytes.decode())
|
2767
|
-
status_code = check.isinstance(out_json['response_code'], int)
|
2768
|
-
|
2769
|
-
with open(out_file, 'rb') as f:
|
2770
|
-
body = f.read()
|
2771
|
-
|
2772
|
-
result = self.CurlResult(
|
2773
|
-
status_code=status_code,
|
2774
|
-
body=body,
|
2775
|
-
)
|
2776
|
-
|
2777
|
-
if raise_ and (500 <= status_code <= 600):
|
2778
|
-
raise result.as_error()
|
2779
|
-
|
2780
|
-
return result
|
2781
|
-
|
2782
|
-
def run_json_curl_cmd(
|
2783
|
-
self,
|
2784
|
-
cmd: ShellCmd,
|
2785
|
-
*,
|
2786
|
-
success_status_codes: ta.Optional[ta.Container[int]] = None,
|
2787
|
-
) -> ta.Optional[ta.Any]:
|
2788
|
-
result = self.run_curl_cmd(cmd, raise_=True)
|
2789
|
-
|
2790
|
-
if success_status_codes is not None:
|
2791
|
-
is_success = result.status_code in success_status_codes
|
2792
|
-
else:
|
2793
|
-
is_success = 200 <= result.status_code < 300
|
2794
|
-
|
2795
|
-
if is_success:
|
2796
|
-
if not (body := result.body):
|
2797
|
-
return None
|
2798
|
-
return json.loads(body.decode('utf-8-sig'))
|
2799
|
-
|
2800
|
-
elif result.status_code == 404:
|
2801
|
-
return None
|
2802
|
-
|
2803
|
-
else:
|
2804
|
-
raise result.as_error()
|
2805
|
-
|
2806
|
-
#
|
2807
|
-
|
2808
|
-
def build_get_entry_curl_cmd(self, key: str) -> ShellCmd:
|
2809
|
-
return self.build_curl_cmd(
|
2810
|
-
'GET',
|
2811
|
-
f'cache?keys={key}',
|
2812
|
-
)
|
2813
|
-
|
2814
|
-
def run_get_entry(self, key: str) -> ta.Optional[GithubCacheServiceV1.ArtifactCacheEntry]:
|
2815
|
-
curl_cmd = self.build_get_entry_curl_cmd(key)
|
2816
|
-
|
2817
|
-
obj = self.run_json_curl_cmd(
|
2818
|
-
curl_cmd,
|
2819
|
-
success_status_codes=[200, 204],
|
2820
|
-
)
|
2821
|
-
if obj is None:
|
2822
|
-
return None
|
2823
|
-
|
2824
|
-
return GithubCacheServiceV1.dataclass_from_json(
|
2825
|
-
GithubCacheServiceV1.ArtifactCacheEntry,
|
2826
|
-
obj,
|
2827
|
-
)
|
2828
|
-
|
2829
|
-
#
|
2830
|
-
|
2831
|
-
def build_download_get_entry_cmd(
|
2832
|
-
self,
|
2833
|
-
entry: GithubCacheServiceV1.ArtifactCacheEntry,
|
2834
|
-
out_file: str,
|
2835
|
-
) -> ShellCmd:
|
2836
|
-
return ShellCmd(' '.join([
|
2837
|
-
'aria2c',
|
2838
|
-
'-x', '4',
|
2839
|
-
'-o', out_file,
|
2840
|
-
check.non_empty_str(entry.archive_location),
|
2841
|
-
]))
|
2842
|
-
|
2843
|
-
def download_get_entry(
|
2844
|
-
self,
|
2845
|
-
entry: GithubCacheServiceV1.ArtifactCacheEntry,
|
2846
|
-
out_file: str,
|
2847
|
-
) -> None:
|
2848
|
-
dl_cmd = self.build_download_get_entry_cmd(entry, out_file)
|
2849
|
-
dl_cmd.run(subprocesses.check_call)
|
2850
|
-
|
2851
|
-
#
|
2852
|
-
|
2853
|
-
def upload_cache_entry(
|
2854
|
-
self,
|
2855
|
-
key: str,
|
2856
|
-
in_file: str,
|
2857
|
-
) -> None:
|
2858
|
-
check.state(os.path.isfile(in_file))
|
2859
|
-
|
2860
|
-
file_size = os.stat(in_file).st_size
|
2861
|
-
|
2862
|
-
reserve_req = GithubCacheServiceV1.ReserveCacheRequest(
|
2863
|
-
key=key,
|
2864
|
-
cache_size=file_size,
|
2865
|
-
)
|
2866
|
-
reserve_cmd = self.build_post_json_curl_cmd(
|
2867
|
-
'caches',
|
2868
|
-
GithubCacheServiceV1.dataclass_to_json(reserve_req),
|
2869
|
-
)
|
2870
|
-
reserve_resp_obj: ta.Any = check.not_none(self.run_json_curl_cmd(
|
2871
|
-
reserve_cmd,
|
2872
|
-
success_status_codes=[201],
|
2873
|
-
))
|
2874
|
-
reserve_resp = GithubCacheServiceV1.dataclass_from_json( # noqa
|
2875
|
-
GithubCacheServiceV1.ReserveCacheResponse,
|
2876
|
-
reserve_resp_obj,
|
2877
|
-
)
|
2878
|
-
|
2879
|
-
raise NotImplementedError
|
2880
|
-
|
2881
|
-
|
2882
|
-
##
|
2883
|
-
|
2884
|
-
|
2885
|
-
class GithubShellCache(ShellCache):
|
2886
|
-
def __init__(
|
2887
|
-
self,
|
2888
|
-
dir: str, # noqa
|
2889
|
-
*,
|
2890
|
-
client: ta.Optional[GithubV1CacheShellClient] = None,
|
2891
|
-
) -> None:
|
2892
|
-
super().__init__()
|
3582
|
+
manifests = json.loads(m.decode('utf-8'))
|
3583
|
+
manifest = check.single(manifests)
|
3584
|
+
tag = check.non_empty_str(check.single(manifest['RepoTags']))
|
3585
|
+
return tag
|
2893
3586
|
|
2894
|
-
self._dir = check.not_none(dir)
|
2895
3587
|
|
2896
|
-
|
2897
|
-
|
2898
|
-
|
3588
|
+
def read_docker_tar_image_id(tar_file: str) -> str:
|
3589
|
+
with tarfile.open(tar_file) as tf:
|
3590
|
+
with contextlib.closing(check.not_none(tf.extractfile('index.json'))) as mf:
|
3591
|
+
i = mf.read()
|
2899
3592
|
|
2900
|
-
|
3593
|
+
index = json.loads(i.decode('utf-8'))
|
3594
|
+
manifest = check.single(index['manifests'])
|
3595
|
+
image_id = check.non_empty_str(manifest['digest'])
|
3596
|
+
return image_id
|
2901
3597
|
|
2902
|
-
def get_file_cmd(self, key: str) -> ta.Optional[ShellCmd]:
|
2903
|
-
local_file = self._local.get_cache_file_path(key)
|
2904
|
-
if os.path.exists(local_file):
|
2905
|
-
return ShellCmd(f'cat {shlex.quote(local_file)}')
|
2906
3598
|
|
2907
|
-
|
2908
|
-
return None
|
3599
|
+
##
|
2909
3600
|
|
2910
|
-
tmp_file = self._local.format_incomplete_file(local_file)
|
2911
|
-
try:
|
2912
|
-
self._client.download_get_entry(entry, tmp_file)
|
2913
3601
|
|
2914
|
-
|
3602
|
+
async def is_docker_image_present(image: str) -> bool:
|
3603
|
+
out = await asyncio_subprocesses.check_output(
|
3604
|
+
'docker',
|
3605
|
+
'images',
|
3606
|
+
'--format', 'json',
|
3607
|
+
image,
|
3608
|
+
)
|
2915
3609
|
|
2916
|
-
|
2917
|
-
|
3610
|
+
out_s = out.decode('utf-8').strip()
|
3611
|
+
if not out_s:
|
3612
|
+
return False
|
2918
3613
|
|
2919
|
-
|
3614
|
+
json.loads(out_s) # noqa
|
3615
|
+
return True
|
2920
3616
|
|
2921
|
-
return ShellCmd(f'cat {shlex.quote(local_file)}')
|
2922
3617
|
|
2923
|
-
|
2924
|
-
|
2925
|
-
|
2926
|
-
|
2927
|
-
|
2928
|
-
|
2929
|
-
|
2930
|
-
|
2931
|
-
super().__init__()
|
3618
|
+
async def pull_docker_image(
|
3619
|
+
image: str,
|
3620
|
+
) -> None:
|
3621
|
+
await asyncio_subprocesses.check_call(
|
3622
|
+
'docker',
|
3623
|
+
'pull',
|
3624
|
+
image,
|
3625
|
+
)
|
2932
3626
|
|
2933
|
-
self._owner = owner
|
2934
|
-
self._key = key
|
2935
|
-
self._tmp_file = tmp_file
|
2936
|
-
self._local_file = local_file
|
2937
3627
|
|
2938
|
-
|
2939
|
-
|
2940
|
-
|
3628
|
+
async def build_docker_image(
|
3629
|
+
docker_file: str,
|
3630
|
+
*,
|
3631
|
+
tag: ta.Optional[str] = None,
|
3632
|
+
cwd: ta.Optional[str] = None,
|
3633
|
+
run_options: ta.Optional[ta.Sequence[str]] = None,
|
3634
|
+
) -> str:
|
3635
|
+
with temp_file_context() as id_file:
|
3636
|
+
await asyncio_subprocesses.check_call(
|
3637
|
+
'docker',
|
3638
|
+
'build',
|
3639
|
+
'-f', os.path.abspath(docker_file),
|
3640
|
+
'--iidfile', id_file,
|
3641
|
+
*(['--tag', tag] if tag is not None else []),
|
3642
|
+
*(run_options or []),
|
3643
|
+
'.',
|
3644
|
+
**(dict(cwd=cwd) if cwd is not None else {}),
|
3645
|
+
)
|
2941
3646
|
|
2942
|
-
|
2943
|
-
|
3647
|
+
with open(id_file) as f: # noqa
|
3648
|
+
image_id = check.single(f.read().strip().splitlines()).strip()
|
2944
3649
|
|
2945
|
-
|
3650
|
+
return image_id
|
2946
3651
|
|
2947
|
-
def _abort(self) -> None:
|
2948
|
-
os.unlink(self._tmp_file)
|
2949
3652
|
|
2950
|
-
|
2951
|
-
|
2952
|
-
|
2953
|
-
|
2954
|
-
|
2955
|
-
|
2956
|
-
|
2957
|
-
)
|
3653
|
+
async def tag_docker_image(image: str, tag: str) -> None:
|
3654
|
+
await asyncio_subprocesses.check_call(
|
3655
|
+
'docker',
|
3656
|
+
'tag',
|
3657
|
+
image,
|
3658
|
+
tag,
|
3659
|
+
)
|
2958
3660
|
|
2959
3661
|
|
2960
|
-
|
2961
|
-
|
2962
|
-
|
2963
|
-
|
2964
|
-
|
2965
|
-
|
2966
|
-
- req.txt format aware hash
|
2967
|
-
- more than just whitespace
|
2968
|
-
- pyproject req rewriting
|
2969
|
-
- download_requirements bootstrap off prev? not worth the dl?
|
2970
|
-
- big deps (torch) change less, probably worth it
|
2971
|
-
- follow embedded -r automatically like pyp
|
2972
|
-
"""
|
3662
|
+
async def delete_docker_tag(tag: str) -> None:
|
3663
|
+
await asyncio_subprocesses.check_call(
|
3664
|
+
'docker',
|
3665
|
+
'rmi',
|
3666
|
+
tag,
|
3667
|
+
)
|
2973
3668
|
|
2974
3669
|
|
2975
3670
|
##
|
2976
3671
|
|
2977
3672
|
|
2978
|
-
def
|
2979
|
-
|
2980
|
-
|
2981
|
-
|
3673
|
+
async def save_docker_tar_cmd(
|
3674
|
+
image: str,
|
3675
|
+
output_cmd: ShellCmd,
|
3676
|
+
) -> None:
|
3677
|
+
cmd = dc.replace(output_cmd, s=f'docker save {image} | {output_cmd.s}')
|
3678
|
+
await cmd.run(asyncio_subprocesses.check_call)
|
2982
3679
|
|
2983
|
-
for txt_file in requirements_txts:
|
2984
|
-
txt_file_name = os.path.basename(txt_file)
|
2985
|
-
check.not_in(txt_file_name, txt_file_contents)
|
2986
|
-
with open(txt_file) as f:
|
2987
|
-
txt_contents = f.read()
|
2988
|
-
txt_file_contents[txt_file_name] = txt_contents
|
2989
3680
|
|
2990
|
-
|
3681
|
+
async def save_docker_tar(
|
3682
|
+
image: str,
|
3683
|
+
tar_file: str,
|
3684
|
+
) -> None:
|
3685
|
+
return await save_docker_tar_cmd(
|
3686
|
+
image,
|
3687
|
+
ShellCmd(f'cat > {shlex.quote(tar_file)}'),
|
3688
|
+
)
|
2991
3689
|
|
2992
|
-
lines = []
|
2993
|
-
for txt_file, txt_contents in sorted(txt_file_contents.items()):
|
2994
|
-
txt_hash = sha256_str(txt_contents)
|
2995
|
-
lines.append(f'{txt_file}={txt_hash}')
|
2996
3690
|
|
2997
|
-
|
3691
|
+
#
|
2998
3692
|
|
2999
3693
|
|
3000
|
-
|
3694
|
+
async def load_docker_tar_cmd(
|
3695
|
+
input_cmd: ShellCmd,
|
3696
|
+
) -> str:
|
3697
|
+
cmd = dc.replace(input_cmd, s=f'{input_cmd.s} | docker load')
|
3001
3698
|
|
3699
|
+
out = (await cmd.run(asyncio_subprocesses.check_output)).decode()
|
3002
3700
|
|
3003
|
-
|
3004
|
-
|
3005
|
-
|
3006
|
-
requirements_txts: ta.Sequence[str],
|
3007
|
-
) -> None:
|
3008
|
-
requirements_txt_dir = tempfile.mkdtemp()
|
3009
|
-
with defer(lambda: shutil.rmtree(requirements_txt_dir)):
|
3010
|
-
for rt in requirements_txts:
|
3011
|
-
shutil.copyfile(rt, os.path.join(requirements_txt_dir, os.path.basename(rt)))
|
3701
|
+
line = check.single(out.strip().splitlines())
|
3702
|
+
loaded = line.partition(':')[2].strip()
|
3703
|
+
return loaded
|
3012
3704
|
|
3013
|
-
|
3014
|
-
|
3015
|
-
|
3016
|
-
|
3017
|
-
|
3018
|
-
'-v', f'{os.path.abspath(requirements_dir)}:/requirements',
|
3019
|
-
'-v', f'{requirements_txt_dir}:/requirements_txt',
|
3020
|
-
image,
|
3021
|
-
'pip',
|
3022
|
-
'download',
|
3023
|
-
'-d', '/requirements',
|
3024
|
-
*itertools.chain.from_iterable(
|
3025
|
-
['-r', f'/requirements_txt/{os.path.basename(rt)}']
|
3026
|
-
for rt in requirements_txts
|
3027
|
-
),
|
3028
|
-
)
|
3705
|
+
|
3706
|
+
async def load_docker_tar(
|
3707
|
+
tar_file: str,
|
3708
|
+
) -> str:
|
3709
|
+
return await load_docker_tar_cmd(ShellCmd(f'cat {shlex.quote(tar_file)}'))
|
3029
3710
|
|
3030
3711
|
|
3031
3712
|
########################################
|
3032
3713
|
# ../ci.py
|
3033
3714
|
|
3034
3715
|
|
3035
|
-
class Ci(
|
3036
|
-
|
3716
|
+
class Ci(AsyncExitStacked):
|
3717
|
+
KEY_HASH_LEN = 16
|
3037
3718
|
|
3038
3719
|
@dc.dataclass(frozen=True)
|
3039
3720
|
class Config:
|
@@ -3046,9 +3727,18 @@ class Ci(ExitStacked):
|
|
3046
3727
|
|
3047
3728
|
cmd: ShellCmd
|
3048
3729
|
|
3730
|
+
#
|
3731
|
+
|
3049
3732
|
requirements_txts: ta.Optional[ta.Sequence[str]] = None
|
3050
3733
|
|
3051
3734
|
always_pull: bool = False
|
3735
|
+
always_build: bool = False
|
3736
|
+
|
3737
|
+
no_dependencies: bool = False
|
3738
|
+
|
3739
|
+
run_options: ta.Optional[ta.Sequence[str]] = None
|
3740
|
+
|
3741
|
+
#
|
3052
3742
|
|
3053
3743
|
def __post_init__(self) -> None:
|
3054
3744
|
check.not_isinstance(self.requirements_txts, str)
|
@@ -3057,44 +3747,17 @@ class Ci(ExitStacked):
|
|
3057
3747
|
self,
|
3058
3748
|
cfg: Config,
|
3059
3749
|
*,
|
3060
|
-
shell_cache: ta.Optional[ShellCache] = None,
|
3061
3750
|
file_cache: ta.Optional[FileCache] = None,
|
3062
3751
|
) -> None:
|
3063
3752
|
super().__init__()
|
3064
3753
|
|
3065
3754
|
self._cfg = cfg
|
3066
|
-
self._shell_cache = shell_cache
|
3067
3755
|
self._file_cache = file_cache
|
3068
3756
|
|
3069
3757
|
#
|
3070
3758
|
|
3071
|
-
def
|
3072
|
-
if self.
|
3073
|
-
return None
|
3074
|
-
|
3075
|
-
get_cache_cmd = self._shell_cache.get_file_cmd(key)
|
3076
|
-
if get_cache_cmd is None:
|
3077
|
-
return None
|
3078
|
-
|
3079
|
-
get_cache_cmd = dc.replace(get_cache_cmd, s=f'{get_cache_cmd.s} | zstd -cd --long') # noqa
|
3080
|
-
|
3081
|
-
return load_docker_tar_cmd(get_cache_cmd)
|
3082
|
-
|
3083
|
-
def _save_cache_docker_image(self, key: str, image: str) -> None:
|
3084
|
-
if self._shell_cache is None:
|
3085
|
-
return
|
3086
|
-
|
3087
|
-
with self._shell_cache.put_file_cmd(key) as put_cache:
|
3088
|
-
put_cache_cmd = put_cache.cmd
|
3089
|
-
|
3090
|
-
put_cache_cmd = dc.replace(put_cache_cmd, s=f'zstd | {put_cache_cmd.s}')
|
3091
|
-
|
3092
|
-
save_docker_tar_cmd(image, put_cache_cmd)
|
3093
|
-
|
3094
|
-
#
|
3095
|
-
|
3096
|
-
def _load_docker_image(self, image: str) -> None:
|
3097
|
-
if not self._cfg.always_pull and is_docker_image_present(image):
|
3759
|
+
async def _load_docker_image(self, image: str) -> None:
|
3760
|
+
if not self._cfg.always_pull and (await is_docker_image_present(image)):
|
3098
3761
|
return
|
3099
3762
|
|
3100
3763
|
dep_suffix = image
|
@@ -3102,181 +3765,202 @@ class Ci(ExitStacked):
|
|
3102
3765
|
dep_suffix = dep_suffix.replace(c, '-')
|
3103
3766
|
|
3104
3767
|
cache_key = f'docker-{dep_suffix}'
|
3105
|
-
if self._load_cache_docker_image(cache_key) is not None:
|
3768
|
+
if (await self._load_cache_docker_image(cache_key)) is not None:
|
3106
3769
|
return
|
3107
3770
|
|
3108
|
-
pull_docker_image(image)
|
3771
|
+
await pull_docker_image(image)
|
3109
3772
|
|
3110
|
-
self._save_cache_docker_image(cache_key, image)
|
3773
|
+
await self._save_cache_docker_image(cache_key, image)
|
3111
3774
|
|
3112
|
-
def load_docker_image(self, image: str) -> None:
|
3775
|
+
async def load_docker_image(self, image: str) -> None:
|
3113
3776
|
with log_timing_context(f'Load docker image: {image}'):
|
3114
|
-
self._load_docker_image(image)
|
3777
|
+
await self._load_docker_image(image)
|
3115
3778
|
|
3116
|
-
|
3117
|
-
def load_compose_service_dependencies(self) -> None:
|
3118
|
-
deps = get_compose_service_dependencies(
|
3119
|
-
self._cfg.compose_file,
|
3120
|
-
self._cfg.service,
|
3121
|
-
)
|
3779
|
+
#
|
3122
3780
|
|
3123
|
-
|
3124
|
-
|
3781
|
+
async def _load_cache_docker_image(self, key: str) -> ta.Optional[str]:
|
3782
|
+
if self._file_cache is None:
|
3783
|
+
return None
|
3125
3784
|
|
3126
|
-
|
3785
|
+
cache_file = await self._file_cache.get_file(key)
|
3786
|
+
if cache_file is None:
|
3787
|
+
return None
|
3127
3788
|
|
3128
|
-
|
3129
|
-
docker_file_hash = build_docker_file_hash(self._cfg.docker_file)[:self.FILE_NAME_HASH_LEN]
|
3789
|
+
get_cache_cmd = ShellCmd(f'cat {cache_file} | zstd -cd --long')
|
3130
3790
|
|
3131
|
-
|
3132
|
-
if (cache_image_id := self._load_cache_docker_image(cache_key)) is not None:
|
3133
|
-
return cache_image_id
|
3791
|
+
return await load_docker_tar_cmd(get_cache_cmd)
|
3134
3792
|
|
3135
|
-
|
3136
|
-
|
3137
|
-
|
3138
|
-
)
|
3793
|
+
async def _save_cache_docker_image(self, key: str, image: str) -> None:
|
3794
|
+
if self._file_cache is None:
|
3795
|
+
return
|
3139
3796
|
|
3140
|
-
|
3797
|
+
with temp_file_context() as tmp_file:
|
3798
|
+
write_tmp_cmd = ShellCmd(f'zstd > {tmp_file}')
|
3141
3799
|
|
3142
|
-
|
3800
|
+
await save_docker_tar_cmd(image, write_tmp_cmd)
|
3143
3801
|
|
3144
|
-
|
3145
|
-
def resolve_ci_image(self) -> str:
|
3146
|
-
with log_timing_context('Resolve ci image') as ltc:
|
3147
|
-
image_id = self._resolve_ci_image()
|
3148
|
-
ltc.set_description(f'Resolve ci image: {image_id}')
|
3149
|
-
return image_id
|
3802
|
+
await self._file_cache.put_file(key, tmp_file, steal=True)
|
3150
3803
|
|
3151
3804
|
#
|
3152
3805
|
|
3153
|
-
def
|
3154
|
-
|
3155
|
-
|
3156
|
-
|
3157
|
-
|
3806
|
+
async def _resolve_docker_image(
|
3807
|
+
self,
|
3808
|
+
cache_key: str,
|
3809
|
+
build_and_tag: ta.Callable[[str], ta.Awaitable[str]],
|
3810
|
+
) -> str:
|
3811
|
+
image_tag = f'{self._cfg.service}:{cache_key}'
|
3158
3812
|
|
3159
|
-
|
3813
|
+
if not self._cfg.always_build and (await is_docker_image_present(image_tag)):
|
3814
|
+
return image_tag
|
3815
|
+
|
3816
|
+
if (cache_image_id := await self._load_cache_docker_image(cache_key)) is not None:
|
3817
|
+
await tag_docker_image(
|
3818
|
+
cache_image_id,
|
3819
|
+
image_tag,
|
3820
|
+
)
|
3821
|
+
return image_tag
|
3160
3822
|
|
3161
|
-
|
3162
|
-
tar_file_name = f'{tar_file_key}.tar'
|
3823
|
+
image_id = await build_and_tag(image_tag)
|
3163
3824
|
|
3164
|
-
|
3165
|
-
self._enter_context(defer(lambda: shutil.rmtree(temp_dir))) # noqa
|
3825
|
+
await self._save_cache_docker_image(cache_key, image_id)
|
3166
3826
|
|
3167
|
-
|
3168
|
-
with tarfile.open(cache_tar_file) as tar:
|
3169
|
-
tar.extractall(path=temp_dir) # noqa
|
3827
|
+
return image_tag
|
3170
3828
|
|
3171
|
-
|
3829
|
+
#
|
3172
3830
|
|
3173
|
-
|
3174
|
-
|
3831
|
+
@cached_nullary
|
3832
|
+
def docker_file_hash(self) -> str:
|
3833
|
+
return build_docker_file_hash(self._cfg.docker_file)[:self.KEY_HASH_LEN]
|
3834
|
+
|
3835
|
+
async def _resolve_ci_base_image(self) -> str:
|
3836
|
+
async def build_and_tag(image_tag: str) -> str:
|
3837
|
+
return await build_docker_image(
|
3838
|
+
self._cfg.docker_file,
|
3839
|
+
tag=image_tag,
|
3840
|
+
cwd=self._cfg.project_dir,
|
3841
|
+
)
|
3175
3842
|
|
3176
|
-
|
3177
|
-
self.resolve_ci_image(),
|
3178
|
-
temp_requirements_dir,
|
3179
|
-
requirements_txts,
|
3180
|
-
)
|
3843
|
+
cache_key = f'ci-base-{self.docker_file_hash()}'
|
3181
3844
|
|
3182
|
-
|
3183
|
-
temp_tar_file = os.path.join(temp_dir, tar_file_name)
|
3845
|
+
return await self._resolve_docker_image(cache_key, build_and_tag)
|
3184
3846
|
|
3185
|
-
|
3186
|
-
|
3187
|
-
|
3188
|
-
|
3189
|
-
|
3190
|
-
|
3847
|
+
@async_cached_nullary
|
3848
|
+
async def resolve_ci_base_image(self) -> str:
|
3849
|
+
with log_timing_context('Resolve ci base image') as ltc:
|
3850
|
+
image_id = await self._resolve_ci_base_image()
|
3851
|
+
ltc.set_description(f'Resolve ci base image: {image_id}')
|
3852
|
+
return image_id
|
3191
3853
|
|
3192
|
-
|
3854
|
+
#
|
3193
3855
|
|
3194
|
-
|
3856
|
+
@cached_nullary
|
3857
|
+
def requirements_txts(self) -> ta.Sequence[str]:
|
3858
|
+
return [
|
3859
|
+
os.path.join(self._cfg.project_dir, rf)
|
3860
|
+
for rf in check.not_none(self._cfg.requirements_txts)
|
3861
|
+
]
|
3195
3862
|
|
3196
3863
|
@cached_nullary
|
3197
|
-
def
|
3198
|
-
|
3199
|
-
|
3200
|
-
|
3201
|
-
|
3864
|
+
def requirements_hash(self) -> str:
|
3865
|
+
return build_requirements_hash(self.requirements_txts())[:self.KEY_HASH_LEN]
|
3866
|
+
|
3867
|
+
async def _resolve_ci_image(self) -> str:
|
3868
|
+
async def build_and_tag(image_tag: str) -> str:
|
3869
|
+
base_image = await self.resolve_ci_base_image()
|
3870
|
+
|
3871
|
+
setup_cmds = [
|
3872
|
+
' '.join([
|
3873
|
+
'pip install',
|
3874
|
+
'--no-cache-dir',
|
3875
|
+
'--root-user-action ignore',
|
3876
|
+
'uv',
|
3877
|
+
]),
|
3878
|
+
' '.join([
|
3879
|
+
'uv pip install',
|
3880
|
+
'--no-cache',
|
3881
|
+
'--index-strategy unsafe-best-match',
|
3882
|
+
'--system',
|
3883
|
+
*[f'-r /project/{rf}' for rf in self._cfg.requirements_txts or []],
|
3884
|
+
]),
|
3885
|
+
]
|
3886
|
+
setup_cmd = ' && '.join(setup_cmds)
|
3887
|
+
|
3888
|
+
docker_file_lines = [
|
3889
|
+
f'FROM {base_image}',
|
3890
|
+
'RUN mkdir /project',
|
3891
|
+
*[f'COPY {rf} /project/{rf}' for rf in self._cfg.requirements_txts or []],
|
3892
|
+
f'RUN {setup_cmd}',
|
3893
|
+
'RUN rm /project/*',
|
3894
|
+
'WORKDIR /project',
|
3895
|
+
]
|
3896
|
+
|
3897
|
+
with temp_file_context() as docker_file:
|
3898
|
+
with open(docker_file, 'w') as f: # noqa
|
3899
|
+
f.write('\n'.join(docker_file_lines))
|
3900
|
+
|
3901
|
+
return await build_docker_image(
|
3902
|
+
docker_file,
|
3903
|
+
tag=image_tag,
|
3904
|
+
cwd=self._cfg.project_dir,
|
3905
|
+
)
|
3906
|
+
|
3907
|
+
cache_key = f'ci-{self.docker_file_hash()}-{self.requirements_hash()}'
|
3908
|
+
|
3909
|
+
return await self._resolve_docker_image(cache_key, build_and_tag)
|
3910
|
+
|
3911
|
+
@async_cached_nullary
|
3912
|
+
async def resolve_ci_image(self) -> str:
|
3913
|
+
with log_timing_context('Resolve ci image') as ltc:
|
3914
|
+
image_id = await self._resolve_ci_image()
|
3915
|
+
ltc.set_description(f'Resolve ci image: {image_id}')
|
3916
|
+
return image_id
|
3202
3917
|
|
3203
3918
|
#
|
3204
3919
|
|
3205
|
-
|
3206
|
-
|
3207
|
-
|
3208
|
-
|
3209
|
-
|
3210
|
-
|
3211
|
-
),
|
3212
|
-
]
|
3213
|
-
|
3214
|
-
#
|
3920
|
+
@async_cached_nullary
|
3921
|
+
async def load_dependencies(self) -> None:
|
3922
|
+
deps = get_compose_service_dependencies(
|
3923
|
+
self._cfg.compose_file,
|
3924
|
+
self._cfg.service,
|
3925
|
+
)
|
3215
3926
|
|
3216
|
-
|
3217
|
-
|
3218
|
-
f'({self._cfg.cmd.s})',
|
3219
|
-
]))
|
3927
|
+
for dep_image in deps.values():
|
3928
|
+
await self.load_docker_image(dep_image)
|
3220
3929
|
|
3221
|
-
|
3930
|
+
#
|
3222
3931
|
|
3223
|
-
|
3932
|
+
async def _run_compose_(self) -> None:
|
3933
|
+
async with DockerComposeRun(DockerComposeRun.Config(
|
3224
3934
|
compose_file=self._cfg.compose_file,
|
3225
3935
|
service=self._cfg.service,
|
3226
3936
|
|
3227
|
-
image=self.resolve_ci_image(),
|
3937
|
+
image=await self.resolve_ci_image(),
|
3228
3938
|
|
3229
|
-
cmd=
|
3939
|
+
cmd=self._cfg.cmd,
|
3230
3940
|
|
3231
3941
|
run_options=[
|
3232
3942
|
'-v', f'{os.path.abspath(self._cfg.project_dir)}:/project',
|
3233
|
-
|
3943
|
+
*(self._cfg.run_options or []),
|
3234
3944
|
],
|
3235
3945
|
|
3236
3946
|
cwd=self._cfg.project_dir,
|
3947
|
+
|
3948
|
+
no_dependencies=self._cfg.no_dependencies,
|
3237
3949
|
)) as ci_compose_run:
|
3238
|
-
ci_compose_run.run()
|
3950
|
+
await ci_compose_run.run()
|
3239
3951
|
|
3240
|
-
def _run_compose(self) -> None:
|
3952
|
+
async def _run_compose(self) -> None:
|
3241
3953
|
with log_timing_context('Run compose'):
|
3242
|
-
self._run_compose_()
|
3954
|
+
await self._run_compose_()
|
3243
3955
|
|
3244
3956
|
#
|
3245
3957
|
|
3246
|
-
def run(self) -> None:
|
3247
|
-
self.
|
3248
|
-
|
3249
|
-
self.resolve_ci_image()
|
3250
|
-
|
3251
|
-
self.resolve_requirements_dir()
|
3252
|
-
|
3253
|
-
self._run_compose()
|
3254
|
-
|
3255
|
-
|
3256
|
-
########################################
|
3257
|
-
# ../github/cli.py
|
3258
|
-
"""
|
3259
|
-
See:
|
3260
|
-
- https://docs.github.com/en/rest/actions/cache?apiVersion=2022-11-28
|
3261
|
-
"""
|
3262
|
-
|
3958
|
+
async def run(self) -> None:
|
3959
|
+
await self.resolve_ci_image()
|
3263
3960
|
|
3264
|
-
|
3265
|
-
@argparse_cmd(
|
3266
|
-
argparse_arg('key'),
|
3267
|
-
)
|
3268
|
-
def get_cache_entry(self) -> None:
|
3269
|
-
shell_client = GithubV1CacheShellClient()
|
3270
|
-
entry = shell_client.run_get_entry(self.args.key)
|
3271
|
-
if entry is None:
|
3272
|
-
return
|
3273
|
-
print(json_dumps_pretty(dc.asdict(entry))) # noqa
|
3961
|
+
await self.load_dependencies()
|
3274
3962
|
|
3275
|
-
|
3276
|
-
argparse_arg('repository-id'),
|
3277
|
-
)
|
3278
|
-
def list_cache_entries(self) -> None:
|
3279
|
-
raise NotImplementedError
|
3963
|
+
await self._run_compose()
|
3280
3964
|
|
3281
3965
|
|
3282
3966
|
########################################
|
@@ -3314,8 +3998,8 @@ class CiCli(ArgparseCli):
|
|
3314
3998
|
@argparse_cmd(
|
3315
3999
|
accepts_unknown=True,
|
3316
4000
|
)
|
3317
|
-
def github(self) -> ta.Optional[int]:
|
3318
|
-
return GithubCli(self.unknown_args).
|
4001
|
+
async def github(self) -> ta.Optional[int]:
|
4002
|
+
return await GithubCli(self.unknown_args).async_cli_run()
|
3319
4003
|
|
3320
4004
|
#
|
3321
4005
|
|
@@ -3325,18 +4009,33 @@ class CiCli(ArgparseCli):
|
|
3325
4009
|
argparse_arg('--docker-file'),
|
3326
4010
|
argparse_arg('--compose-file'),
|
3327
4011
|
argparse_arg('-r', '--requirements-txt', action='append'),
|
3328
|
-
|
4012
|
+
|
3329
4013
|
argparse_arg('--cache-dir'),
|
4014
|
+
|
4015
|
+
argparse_arg('--github', action='store_true'),
|
4016
|
+
argparse_arg('--github-detect', action='store_true'),
|
4017
|
+
|
3330
4018
|
argparse_arg('--always-pull', action='store_true'),
|
4019
|
+
argparse_arg('--always-build', action='store_true'),
|
4020
|
+
|
4021
|
+
argparse_arg('--no-dependencies', action='store_true'),
|
4022
|
+
|
4023
|
+
argparse_arg('-e', '--env', action='append'),
|
4024
|
+
argparse_arg('-v', '--volume', action='append'),
|
4025
|
+
|
4026
|
+
argparse_arg('cmd', nargs=argparse.REMAINDER),
|
3331
4027
|
)
|
3332
4028
|
async def run(self) -> None:
|
3333
4029
|
project_dir = self.args.project_dir
|
3334
4030
|
docker_file = self.args.docker_file
|
3335
4031
|
compose_file = self.args.compose_file
|
3336
|
-
service = self.args.service
|
3337
4032
|
requirements_txts = self.args.requirements_txt
|
3338
4033
|
cache_dir = self.args.cache_dir
|
3339
|
-
|
4034
|
+
|
4035
|
+
#
|
4036
|
+
|
4037
|
+
cmd = ' '.join(self.args.cmd)
|
4038
|
+
check.non_empty_str(cmd)
|
3340
4039
|
|
3341
4040
|
#
|
3342
4041
|
|
@@ -3348,6 +4047,7 @@ class CiCli(ArgparseCli):
|
|
3348
4047
|
for alt in alts:
|
3349
4048
|
alt_file = os.path.abspath(os.path.join(project_dir, alt))
|
3350
4049
|
if os.path.isfile(alt_file):
|
4050
|
+
log.debug('Using %s', alt_file)
|
3351
4051
|
return alt_file
|
3352
4052
|
return None
|
3353
4053
|
|
@@ -3381,6 +4081,7 @@ class CiCli(ArgparseCli):
|
|
3381
4081
|
'requirements-ci.txt',
|
3382
4082
|
]:
|
3383
4083
|
if os.path.exists(os.path.join(project_dir, rf)):
|
4084
|
+
log.debug('Using %s', rf)
|
3384
4085
|
requirements_txts.append(rf)
|
3385
4086
|
else:
|
3386
4087
|
for rf in requirements_txts:
|
@@ -3388,46 +4089,60 @@ class CiCli(ArgparseCli):
|
|
3388
4089
|
|
3389
4090
|
#
|
3390
4091
|
|
3391
|
-
|
4092
|
+
github = self.args.github
|
4093
|
+
if not github and self.args.github_detect:
|
4094
|
+
github = is_in_github_actions()
|
4095
|
+
if github:
|
4096
|
+
log.debug('Github detected')
|
4097
|
+
|
4098
|
+
#
|
4099
|
+
|
3392
4100
|
file_cache: ta.Optional[FileCache] = None
|
3393
4101
|
if cache_dir is not None:
|
3394
|
-
|
3395
|
-
|
3396
|
-
|
3397
|
-
|
3398
|
-
|
4102
|
+
cache_dir = os.path.abspath(cache_dir)
|
4103
|
+
log.debug('Using cache dir %s', cache_dir)
|
4104
|
+
if github:
|
4105
|
+
file_cache = GithubFileCache(cache_dir)
|
4106
|
+
else:
|
4107
|
+
file_cache = DirectoryFileCache(cache_dir)
|
3399
4108
|
|
3400
|
-
|
4109
|
+
#
|
3401
4110
|
|
3402
|
-
|
3403
|
-
|
3404
|
-
|
3405
|
-
|
4111
|
+
run_options: ta.List[str] = []
|
4112
|
+
for run_arg, run_arg_vals in [
|
4113
|
+
('-e', self.args.env or []),
|
4114
|
+
('-v', self.args.volume or []),
|
4115
|
+
]:
|
4116
|
+
run_options.extend(itertools.chain.from_iterable(
|
4117
|
+
[run_arg, run_arg_val]
|
4118
|
+
for run_arg_val in run_arg_vals
|
4119
|
+
))
|
3406
4120
|
|
3407
4121
|
#
|
3408
4122
|
|
3409
|
-
with Ci(
|
4123
|
+
async with Ci(
|
3410
4124
|
Ci.Config(
|
3411
4125
|
project_dir=project_dir,
|
3412
4126
|
|
3413
4127
|
docker_file=docker_file,
|
3414
4128
|
|
3415
4129
|
compose_file=compose_file,
|
3416
|
-
service=service,
|
4130
|
+
service=self.args.service,
|
3417
4131
|
|
3418
4132
|
requirements_txts=requirements_txts,
|
3419
4133
|
|
3420
|
-
cmd=ShellCmd(
|
3421
|
-
|
3422
|
-
|
3423
|
-
|
4134
|
+
cmd=ShellCmd(cmd),
|
4135
|
+
|
4136
|
+
always_pull=self.args.always_pull,
|
4137
|
+
always_build=self.args.always_build,
|
4138
|
+
|
4139
|
+
no_dependencies=self.args.no_dependencies,
|
3424
4140
|
|
3425
|
-
|
4141
|
+
run_options=run_options,
|
3426
4142
|
),
|
3427
4143
|
file_cache=file_cache,
|
3428
|
-
shell_cache=shell_cache,
|
3429
4144
|
) as ci:
|
3430
|
-
ci.run()
|
4145
|
+
await ci.run()
|
3431
4146
|
|
3432
4147
|
|
3433
4148
|
async def _async_main() -> ta.Optional[int]:
|